homelabby/internal/store/store_test.go
Mikkel Georgsen 623cff0d76 feat(06-01): add conversation and message CRUD methods with integration tests
- CreateConversation, AddMessage, GetConversation, ListConversations on *Store
- ErrNotFound sentinel for unknown conversation IDs
- Message, Conversation, ConversationSummary types
- LIMIT 100 soft guard on ListConversations (T-06-01-04)
- Integration tests cover full round-trip, invalid role, ErrNotFound, ordering
2026-04-10 07:31:12 +00:00

270 lines
7.1 KiB
Go

//go:build integration
package store_test
import (
"context"
"os"
"testing"
"time"
. "git.georgsen.dk/hwlab/internal/store"
)
func testDSN(t *testing.T) string {
t.Helper()
dsn := os.Getenv("HWLAB_DATABASE_URL")
if dsn == "" {
t.Skip("HWLAB_DATABASE_URL not set, skipping integration tests")
}
return dsn
}
// TestNewStore verifies basic connection lifecycle.
func TestNewStore(t *testing.T) {
ctx := context.Background()
t.Run("invalid DSN returns error", func(t *testing.T) {
_, err := NewStore(ctx, "invalid-dsn")
if err == nil {
t.Fatal("expected error for invalid DSN, got nil")
}
})
t.Run("valid DSN returns store", func(t *testing.T) {
dsn := testDSN(t)
s, err := NewStore(ctx, dsn)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer s.Close()
if s.Pool() == nil {
t.Fatal("expected non-nil pool")
}
})
t.Run("Close does not panic", func(t *testing.T) {
dsn := testDSN(t)
s, err := NewStore(ctx, dsn)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
s.Close() // must not panic
})
}
// TestRunMigrations verifies idempotent table creation.
func TestRunMigrations(t *testing.T) {
ctx := context.Background()
dsn := testDSN(t)
s, err := NewStore(ctx, dsn)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer s.Close()
t.Run("first run creates tables", func(t *testing.T) {
if err := RunMigrations(ctx, s.Pool()); err != nil {
t.Fatalf("RunMigrations failed: %v", err)
}
})
t.Run("second run is idempotent", func(t *testing.T) {
if err := RunMigrations(ctx, s.Pool()); err != nil {
t.Fatalf("RunMigrations second call failed: %v", err)
}
})
}
// TestConversationCRUD verifies full CRUD round-trip.
func TestConversationCRUD(t *testing.T) {
ctx := context.Background()
dsn := testDSN(t)
s, err := NewStore(ctx, dsn)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer s.Close()
if err := RunMigrations(ctx, s.Pool()); err != nil {
t.Fatalf("RunMigrations failed: %v", err)
}
t.Run("CreateConversation returns ID", func(t *testing.T) {
convID, err := s.CreateConversation(ctx, "gemma-4-e4b")
if err != nil {
t.Fatalf("CreateConversation failed: %v", err)
}
if convID == "" {
t.Fatal("expected non-empty conversation ID")
}
// Cleanup
defer func() {
_, _ = s.Pool().Exec(ctx, "DELETE FROM conversations WHERE id = $1", convID)
}()
})
t.Run("AddMessage returns message ID", func(t *testing.T) {
convID, err := s.CreateConversation(ctx, "gemma-4-e4b")
if err != nil {
t.Fatalf("CreateConversation failed: %v", err)
}
defer func() {
_, _ = s.Pool().Exec(ctx, "DELETE FROM conversations WHERE id = $1", convID)
}()
msgID, err := s.AddMessage(ctx, convID, "user", "Hello, AI!")
if err != nil {
t.Fatalf("AddMessage failed: %v", err)
}
if msgID == "" {
t.Fatal("expected non-empty message ID")
}
})
t.Run("AddMessage with invalid role returns error", func(t *testing.T) {
convID, err := s.CreateConversation(ctx, "test-model")
if err != nil {
t.Fatalf("CreateConversation failed: %v", err)
}
defer func() {
_, _ = s.Pool().Exec(ctx, "DELETE FROM conversations WHERE id = $1", convID)
}()
_, err = s.AddMessage(ctx, convID, "invalid_role", "content")
if err == nil {
t.Fatal("expected error for invalid role, got nil")
}
})
t.Run("GetConversation returns ErrNotFound for unknown ID", func(t *testing.T) {
conv, err := s.GetConversation(ctx, "00000000-0000-0000-0000-000000000000")
if err != ErrNotFound {
t.Fatalf("expected ErrNotFound, got err=%v conv=%v", err, conv)
}
if conv != nil {
t.Fatal("expected nil conversation for unknown ID")
}
})
t.Run("full round-trip: create, add messages, get", func(t *testing.T) {
convID, err := s.CreateConversation(ctx, "gemma-4-e4b")
if err != nil {
t.Fatalf("CreateConversation failed: %v", err)
}
defer func() {
_, _ = s.Pool().Exec(ctx, "DELETE FROM conversations WHERE id = $1", convID)
}()
msg1ID, err := s.AddMessage(ctx, convID, "user", "What is a switch?")
if err != nil {
t.Fatalf("AddMessage (user) failed: %v", err)
}
// Small sleep to ensure ordering by created_at
time.Sleep(5 * time.Millisecond)
msg2ID, err := s.AddMessage(ctx, convID, "assistant", "A switch is a network device...")
if err != nil {
t.Fatalf("AddMessage (assistant) failed: %v", err)
}
conv, err := s.GetConversation(ctx, convID)
if err != nil {
t.Fatalf("GetConversation failed: %v", err)
}
if conv.ID != convID {
t.Errorf("expected conv ID %s, got %s", convID, conv.ID)
}
if conv.Model != "gemma-4-e4b" {
t.Errorf("expected model gemma-4-e4b, got %s", conv.Model)
}
if len(conv.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(conv.Messages))
}
if conv.Messages[0].ID != msg1ID {
t.Errorf("expected first message ID %s, got %s", msg1ID, conv.Messages[0].ID)
}
if conv.Messages[1].ID != msg2ID {
t.Errorf("expected second message ID %s, got %s", msg2ID, conv.Messages[1].ID)
}
if conv.Messages[0].Role != "user" {
t.Errorf("expected first message role 'user', got %s", conv.Messages[0].Role)
}
if conv.Messages[1].Role != "assistant" {
t.Errorf("expected second message role 'assistant', got %s", conv.Messages[1].Role)
}
})
t.Run("ListConversations returns summaries ordered by started_at DESC", func(t *testing.T) {
// Create two conversations
conv1ID, err := s.CreateConversation(ctx, "model-a")
if err != nil {
t.Fatalf("CreateConversation 1 failed: %v", err)
}
defer func() {
_, _ = s.Pool().Exec(ctx, "DELETE FROM conversations WHERE id = $1", conv1ID)
}()
time.Sleep(5 * time.Millisecond)
conv2ID, err := s.CreateConversation(ctx, "model-b")
if err != nil {
t.Fatalf("CreateConversation 2 failed: %v", err)
}
defer func() {
_, _ = s.Pool().Exec(ctx, "DELETE FROM conversations WHERE id = $1", conv2ID)
}()
// Add a message to conv1
_, err = s.AddMessage(ctx, conv1ID, "user", "hello")
if err != nil {
t.Fatalf("AddMessage failed: %v", err)
}
summaries, err := s.ListConversations(ctx)
if err != nil {
t.Fatalf("ListConversations failed: %v", err)
}
// Find our two conversations in the list
var found1, found2 *ConversationSummary
for i := range summaries {
switch summaries[i].ID {
case conv1ID:
found1 = &summaries[i]
case conv2ID:
found2 = &summaries[i]
}
}
if found1 == nil {
t.Fatal("conv1 not found in ListConversations")
}
if found2 == nil {
t.Fatal("conv2 not found in ListConversations")
}
if found1.MessageCount != 1 {
t.Errorf("expected conv1 message_count=1, got %d", found1.MessageCount)
}
if found2.MessageCount != 0 {
t.Errorf("expected conv2 message_count=0, got %d", found2.MessageCount)
}
// conv2 was created after conv1, so it should appear earlier in DESC order
for i, s := range summaries {
if s.ID == conv2ID {
for j, s2 := range summaries {
if s2.ID == conv1ID {
if i > j {
t.Errorf("expected conv2 (newer) before conv1 (older) in DESC order, but conv2 at index %d, conv1 at index %d", i, j)
}
break
}
}
break
}
}
})
}