1package slug
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "os"
8 "testing"
9
10 "shelley.exe.dev/db"
11 "shelley.exe.dev/llm"
12 "shelley.exe.dev/models"
13)
14
15func TestSanitize(t *testing.T) {
16 tests := []struct {
17 input string
18 expected string
19 }{
20 {"Simple Test", "simple-test"},
21 {"Create a Python Script", "create-a-python-script"},
22 {"Multiple Spaces", "multiple-spaces"},
23 {"Special@#$%Characters", "specialcharacters"},
24 {"Under_Score_Test", "under-score-test"},
25 {"--multiple-hyphens--", "multiple-hyphens"},
26 {"CamelCase Example", "camelcase-example"},
27 {"123 Numbers Test 456", "123-numbers-test-456"},
28 {" leading and trailing ", "leading-and-trailing"},
29 {"", ""},
30 {"Very Long Slug That Might Need To Be Truncated Because It Is Too Long For Normal Use", "very-long-slug-that-might-need-to-be-truncated-because-it-is"},
31 }
32
33 for _, test := range tests {
34 result := Sanitize(test.input)
35 if result != test.expected {
36 t.Errorf("Sanitize(%q) = %q, expected %q", test.input, result, test.expected)
37 }
38 }
39}
40
41// TestGenerateUniqueSlug tests that slug generation adds numeric suffixes when there are conflicts
42func TestGenerateSlug_UniquenessSuffix(t *testing.T) {
43 // This test verifies the numeric suffix logic without needing a real database or LLM
44 // We'll test the error handling and retry logic by mocking the behavior
45
46 // Test the sanitization works as expected first
47 baseSlug := Sanitize("Test Message")
48 expected := "test-message"
49 if baseSlug != expected {
50 t.Errorf("Sanitize failed: got %q, expected %q", baseSlug, expected)
51 }
52
53 // Test that numeric suffixes would be correctly formatted
54 // This mimics what the GenerateSlug function does internally
55 tests := []struct {
56 baseSlug string
57 attempt int
58 expected string
59 }{
60 {"test-message", 0, "test-message-1"},
61 {"test-message", 1, "test-message-2"},
62 {"test-message", 2, "test-message-3"},
63 {"help-python", 9, "help-python-10"},
64 }
65
66 for _, test := range tests {
67 result := fmt.Sprintf("%s-%d", test.baseSlug, test.attempt+1)
68 if result != test.expected {
69 t.Errorf("Suffix generation failed: got %q, expected %q", result, test.expected)
70 }
71 }
72}
73
74// MockLLMService provides a mock LLM service for testing
75type MockLLMService struct {
76 ResponseText string
77}
78
79func (m *MockLLMService) Do(ctx context.Context, req *llm.Request) (*llm.Response, error) {
80 return &llm.Response{
81 Content: []llm.Content{
82 {Type: llm.ContentTypeText, Text: m.ResponseText},
83 },
84 }, nil
85}
86
87func (m *MockLLMService) TokenContextWindow() int {
88 return 8192 // Mock token limit
89}
90
91func (m *MockLLMService) MaxImageDimension() int {
92 return 0 // No limit for mock
93}
94
95// MockLLMProvider provides a mock LLM provider for testing
96type MockLLMProvider struct {
97 Service *MockLLMService
98}
99
100func (m *MockLLMProvider) GetService(modelID string) (llm.Service, error) {
101 return m.Service, nil
102}
103
104func (m *MockLLMProvider) GetAvailableModels() []string {
105 return []string{"mock"}
106}
107
108func (m *MockLLMProvider) GetModelInfo(modelID string) *models.ModelInfo {
109 return nil
110}
111
112// TestGenerateSlug_DatabaseIntegration tests slug generation with actual database conflicts
113func TestGenerateSlug_DatabaseIntegration(t *testing.T) {
114 // Create temporary database
115 tempDB := t.TempDir() + "/slug_test.db"
116 database, err := db.New(db.Config{DSN: tempDB})
117 if err != nil {
118 t.Fatalf("Failed to create test database: %v", err)
119 }
120 defer database.Close()
121
122 // Run migrations
123 ctx := context.Background()
124 if err := database.Migrate(ctx); err != nil {
125 t.Fatalf("Failed to migrate database: %v", err)
126 }
127
128 // Create mock LLM provider that always returns the same slug
129 mockLLM := &MockLLMProvider{
130 Service: &MockLLMService{
131 ResponseText: "test-slug", // Always return the same slug to force conflicts
132 },
133 }
134
135 // Create logger (silent for tests)
136 logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
137 Level: slog.LevelWarn, // Only show warnings and errors
138 }))
139
140 // Create first conversation to establish the base slug
141 conv1, err := database.CreateConversation(ctx, nil, true, nil, nil)
142 if err != nil {
143 t.Fatalf("Failed to create first conversation: %v", err)
144 }
145
146 // Generate first slug - should succeed with "test-slug"
147 slug1, err := GenerateSlug(ctx, mockLLM, database, logger, conv1.ConversationID, "Test message", "test-model")
148 if err != nil {
149 t.Fatalf("Failed to generate first slug: %v", err)
150 }
151 if slug1 != "test-slug" {
152 t.Errorf("Expected first slug to be 'test-slug', got %q", slug1)
153 }
154
155 // Create second conversation
156 conv2, err := database.CreateConversation(ctx, nil, true, nil, nil)
157 if err != nil {
158 t.Fatalf("Failed to create second conversation: %v", err)
159 }
160
161 // Generate second slug - should get "test-slug-1" due to conflict
162 slug2, err := GenerateSlug(ctx, mockLLM, database, logger, conv2.ConversationID, "Test message", "test-model")
163 if err != nil {
164 t.Fatalf("Failed to generate second slug: %v", err)
165 }
166 if slug2 != "test-slug-1" {
167 t.Errorf("Expected second slug to be 'test-slug-1', got %q", slug2)
168 }
169
170 // Create third conversation
171 conv3, err := database.CreateConversation(ctx, nil, true, nil, nil)
172 if err != nil {
173 t.Fatalf("Failed to create third conversation: %v", err)
174 }
175
176 // Generate third slug - should get "test-slug-2" due to conflict
177 slug3, err := GenerateSlug(ctx, mockLLM, database, logger, conv3.ConversationID, "Test message", "test-model")
178 if err != nil {
179 t.Fatalf("Failed to generate third slug: %v", err)
180 }
181 if slug3 != "test-slug-2" {
182 t.Errorf("Expected third slug to be 'test-slug-2', got %q", slug3)
183 }
184
185 // Verify all slugs are different
186 if slug1 == slug2 || slug1 == slug3 || slug2 == slug3 {
187 t.Errorf("All slugs should be unique: slug1=%q, slug2=%q, slug3=%q", slug1, slug2, slug3)
188 }
189
190 t.Logf("Successfully generated unique slugs: %q, %q, %q", slug1, slug2, slug3)
191}
192
193// MockLLMServiceWithError provides a mock LLM service that returns an error
194type MockLLMServiceWithError struct{}
195
196func (m *MockLLMServiceWithError) Do(ctx context.Context, req *llm.Request) (*llm.Response, error) {
197 return nil, fmt.Errorf("LLM service error")
198}
199
200func (m *MockLLMServiceWithError) TokenContextWindow() int {
201 return 8192
202}
203
204func (m *MockLLMServiceWithError) MaxImageDimension() int {
205 return 0
206}
207
208// MockLLMProviderWithError provides a mock LLM provider that returns errors for all models
209type MockLLMProviderWithError struct{}
210
211func (m *MockLLMProviderWithError) GetService(modelID string) (llm.Service, error) {
212 return nil, fmt.Errorf("model not available")
213}
214
215func (m *MockLLMProviderWithError) GetAvailableModels() []string {
216 return []string{}
217}
218
219func (m *MockLLMProviderWithError) GetModelInfo(modelID string) *models.ModelInfo {
220 return nil
221}
222
223// MockLLMProviderWithServiceError provides a mock LLM provider that returns a service with error
224type MockLLMProviderWithServiceError struct{}
225
226func (m *MockLLMProviderWithServiceError) GetService(modelID string) (llm.Service, error) {
227 return &MockLLMServiceWithError{}, nil
228}
229
230func (m *MockLLMProviderWithServiceError) GetAvailableModels() []string {
231 return []string{"mock"}
232}
233
234func (m *MockLLMProviderWithServiceError) GetModelInfo(modelID string) *models.ModelInfo {
235 return nil
236}
237
238// TestGenerateSlug_LLMError tests error handling when LLM service fails
239func TestGenerateSlug_LLMError(t *testing.T) {
240 mockLLM := &MockLLMProviderWithServiceError{}
241
242 logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
243 Level: slog.LevelWarn,
244 }))
245
246 // Test that LLM error is properly propagated (pass a model ID so we get a service)
247 _, err := generateSlugText(context.Background(), mockLLM, logger, "Test message", "test-model")
248 if err == nil {
249 t.Error("Expected error from LLM service, got nil")
250 }
251 if err.Error() != "failed to generate slug: LLM service error" {
252 t.Errorf("Expected LLM service error, got %q", err.Error())
253 }
254}
255
256// TestGenerateSlug_NoModelsAvailable tests error handling when no models are available
257func TestGenerateSlug_NoModelsAvailable(t *testing.T) {
258 mockLLM := &MockLLMProviderWithError{}
259
260 logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
261 Level: slog.LevelWarn,
262 }))
263
264 // Test that error is returned when no models are available
265 _, err := generateSlugText(context.Background(), mockLLM, logger, "Test message", "")
266 if err == nil {
267 t.Error("Expected error when no models available, got nil")
268 }
269 if err.Error() != "no suitable model available for slug generation" {
270 t.Errorf("Expected 'no suitable model' error, got %q", err.Error())
271 }
272}
273
274// TestGenerateSlug_EmptyResponse tests error handling when LLM returns empty response
275func TestGenerateSlug_EmptyResponse(t *testing.T) {
276 // Mock LLM that returns empty response
277 mockLLM := &MockLLMProviderWithEmptyResponse{}
278
279 logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
280 Level: slog.LevelWarn,
281 }))
282
283 _, err := generateSlugText(context.Background(), mockLLM, logger, "Test message", "test-model")
284 if err == nil {
285 t.Error("Expected error for empty LLM response, got nil")
286 }
287 if err.Error() != "empty response from LLM" {
288 t.Errorf("Expected 'empty response' error, got %q", err.Error())
289 }
290}
291
292// MockLLMProviderWithEmptyResponse provides a mock LLM provider that returns empty response
293type MockLLMProviderWithEmptyResponse struct{}
294
295func (m *MockLLMProviderWithEmptyResponse) GetService(modelID string) (llm.Service, error) {
296 return &MockLLMServiceEmptyResponse{}, nil
297}
298
299func (m *MockLLMProviderWithEmptyResponse) GetAvailableModels() []string {
300 return []string{"mock"}
301}
302
303func (m *MockLLMProviderWithEmptyResponse) GetModelInfo(modelID string) *models.ModelInfo {
304 return nil
305}
306
307// MockLLMServiceEmptyResponse provides a mock LLM service that returns empty response
308type MockLLMServiceEmptyResponse struct{}
309
310func (m *MockLLMServiceEmptyResponse) Do(ctx context.Context, req *llm.Request) (*llm.Response, error) {
311 return &llm.Response{
312 Content: []llm.Content{},
313 }, nil
314}
315
316func (m *MockLLMServiceEmptyResponse) TokenContextWindow() int {
317 return 8192
318}
319
320func (m *MockLLMServiceEmptyResponse) MaxImageDimension() int {
321 return 0
322}
323
324// TestGenerateSlug_SanitizationError tests error handling when slug is empty after sanitization
325func TestGenerateSlug_SanitizationError(t *testing.T) {
326 // Mock LLM that returns only special characters that get sanitized away
327 mockLLM := &MockLLMProvider{
328 Service: &MockLLMService{
329 ResponseText: "@#$%^&*()", // All special characters that will be removed
330 },
331 }
332
333 logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
334 Level: slog.LevelWarn,
335 }))
336
337 _, err := generateSlugText(context.Background(), mockLLM, logger, "Test message", "test-model")
338 if err == nil {
339 t.Error("Expected error for empty slug after sanitization, got nil")
340 }
341 if err.Error() != "generated slug is empty after sanitization" {
342 t.Errorf("Expected 'empty after sanitization' error, got %q", err.Error())
343 }
344}
345
346// TestGenerateSlug_MaxAttempts tests the case where we exceed maximum attempts to generate unique slug
347// This test is skipped because it's difficult to set up correctly without modifying the core logic
348func TestGenerateSlug_MaxAttempts(t *testing.T) {
349 t.Skip("Skipping max attempts test due to complexity of setup")
350}
351
352// TestGenerateSlug_DatabaseError tests error handling when database update fails with non-unique error
353func TestGenerateSlug_DatabaseError(t *testing.T) {
354 // Create temporary database
355 tempDB := t.TempDir() + "/slug_db_error_test.db"
356 database, err := db.New(db.Config{DSN: tempDB})
357 if err != nil {
358 t.Fatalf("Failed to create test database: %v", err)
359 }
360 defer func() {
361 if database != nil {
362 database.Close()
363 }
364 }()
365
366 // Run migrations
367 ctx := context.Background()
368 if err := database.Migrate(ctx); err != nil {
369 t.Fatalf("Failed to migrate database: %v", err)
370 }
371
372 // Create mock LLM provider
373 mockLLM := &MockLLMProvider{
374 Service: &MockLLMService{
375 ResponseText: "test-slug",
376 },
377 }
378
379 logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
380 Level: slog.LevelWarn,
381 }))
382
383 // Close database to force error
384 database.Close()
385
386 // Try to generate slug with closed database - pass a valid database object but it's closed
387 closedDB, err := db.New(db.Config{DSN: tempDB})
388 if err != nil {
389 t.Fatalf("Failed to create test database: %v", err)
390 }
391 closedDB.Close()
392
393 _, err = GenerateSlug(ctx, mockLLM, closedDB, logger, "test-conversation-id", "Test message", "test-model")
394 if err == nil {
395 t.Error("Expected database error, got nil")
396 }
397}
398
399// TestGenerateSlug_PredictableModel tests the case where conversation uses predictable model
400func TestGenerateSlug_PredictableModel(t *testing.T) {
401 // Mock LLM that has predictable model available
402 mockLLM := &MockLLMProvider{
403 Service: &MockLLMService{
404 ResponseText: "predictable-slug",
405 },
406 }
407
408 logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
409 Level: slog.LevelDebug,
410 }))
411
412 // Test that predictable model is used when conversationModelID is "predictable"
413 slug, err := generateSlugText(context.Background(), mockLLM, logger, "Test message", "predictable")
414 if err != nil {
415 t.Fatalf("Failed to generate slug with predictable model: %v", err)
416 }
417 if slug != "predictable-slug" {
418 t.Errorf("Expected 'predictable-slug', got %q", slug)
419 }
420}
421
422// TestGenerateSlug_ConversationModelFallback tests fallback to conversation model when no slug-tagged models exist
423func TestGenerateSlug_ConversationModelFallback(t *testing.T) {
424 // Mock LLM provider that doesn't have predictable model but has a conversation model
425 mockLLM := &MockLLMProviderPredictableFallback{
426 fallbackService: &MockLLMService{
427 ResponseText: "fallback-slug",
428 },
429 }
430
431 logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
432 Level: slog.LevelDebug,
433 }))
434
435 // Test that fallback to conversation model works when no slug-tagged models exist
436 slug, err := generateSlugText(context.Background(), mockLLM, logger, "Test message", "my-custom-model")
437 if err != nil {
438 t.Fatalf("Failed to generate slug with conversation model fallback: %v", err)
439 }
440 if slug != "fallback-slug" {
441 t.Errorf("Expected 'fallback-slug', got %q", slug)
442 }
443}
444
445// MockLLMProviderPredictableFallback provides a mock LLM provider that simulates predictable model not available
446type MockLLMProviderPredictableFallback struct {
447 fallbackService *MockLLMService
448}
449
450func (m *MockLLMProviderPredictableFallback) GetService(modelID string) (llm.Service, error) {
451 if modelID == "predictable" {
452 return nil, fmt.Errorf("predictable model not available")
453 }
454 return m.fallbackService, nil
455}
456
457func (m *MockLLMProviderPredictableFallback) GetAvailableModels() []string {
458 return []string{"my-custom-model"}
459}
460
461func (m *MockLLMProviderPredictableFallback) GetModelInfo(modelID string) *models.ModelInfo {
462 return nil
463}