Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ kh_data/
.browser-test/
/knowledgehub
.rodney/
*.out
*.log
123 changes: 123 additions & 0 deletions internal/ai/client_coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package ai

import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestComplete_InvalidJSONResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("definitely not json {{{"))
}))
defer server.Close()

client := NewClient("key", "model")
client.BaseURL = server.URL

_, err := client.Complete([]Message{{Role: "user", Content: "test"}})
if err == nil {
t.Error("expected error for malformed JSON response")
}
}

func TestCompleteStream_InvalidJSONChunks(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
// Invalid JSON in data line — should be skipped
fmt.Fprintln(w, `data: {broken json}`)
fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"valid"}}]}`)
fmt.Fprintln(w, "data: [DONE]")
}))
defer server.Close()

client := NewClient("key", "model")
client.BaseURL = server.URL

var chunks []string
err := client.CompleteStream(
[]Message{{Role: "user", Content: "test"}},
func(chunk string) error {
chunks = append(chunks, chunk)
return nil
},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(chunks) != 1 || chunks[0] != "valid" {
t.Errorf("expected [valid], got %v", chunks)
}
}

func TestCompleteStream_EmptyChoicesSkipped(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintln(w, `data: {"choices":[]}`)
fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":""}}]}`)
fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"real"}}]}`)
fmt.Fprintln(w, "data: [DONE]")
}))
defer server.Close()

client := NewClient("key", "model")
client.BaseURL = server.URL

var chunks []string
err := client.CompleteStream(
[]Message{{Role: "user", Content: "test"}},
func(chunk string) error {
chunks = append(chunks, chunk)
return nil
},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(chunks) != 1 || chunks[0] != "real" {
t.Errorf("expected [real], got %v", chunks)
}
}

func TestSetCompleteFunc_RestoresOriginal(t *testing.T) {
called := false
restore := SetCompleteFunc(func(apiKey, model string, messages []Message) (string, error) {
called = true
return "mocked", nil
})

result, err := callComplete("key", "model", []Message{{Role: "user", Content: "test"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !called {
t.Error("custom function should have been called")
}
if result != "mocked" {
t.Errorf("result = %q, want 'mocked'", result)
}

restore()
}

func TestComplete_ResponseBodyInError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid model parameter"}`))
}))
defer server.Close()

client := NewClient("key", "model")
client.BaseURL = server.URL

_, err := client.Complete([]Message{{Role: "user", Content: "test"}})
if err == nil {
t.Error("expected error")
}
if !strings.Contains(err.Error(), "invalid model parameter") {
t.Errorf("error should contain response body: %v", err)
}
}
80 changes: 80 additions & 0 deletions internal/ai/error_paths_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package ai

import (
"testing"

"github.com/jgordijn/knowledgehub/internal/testutil"
)

// ============================================================
// preference.go:18 — CheckAndRegeneratePreferences countCorrections error
// ============================================================

func TestCheckAndRegeneratePreferences_DBError(t *testing.T) {
app, cleanup := testutil.NewTestApp(t)
defer cleanup()

testutil.CreateSetting(t, app, "openrouter_api_key", "test-key")
testutil.CreateSetting(t, app, "openrouter_model", "test-model")

// Delete entries collection to make countCorrectionsSinceLastProfile fail
col, err := app.FindCollectionByNameOrId("entries")
if err != nil {
t.Fatalf("finding entries collection: %v", err)
}
if err := app.Delete(col); err != nil {
t.Fatalf("deleting entries collection: %v", err)
}

// Should not panic, should log and return
CheckAndRegeneratePreferences(app)
}

// ============================================================
// preference.go:100 — savePreferenceProfile preferences collection missing
// ============================================================

func TestSavePreferenceProfile_CollectionMissing(t *testing.T) {
app, cleanup := testutil.NewTestApp(t)
defer cleanup()

// Delete preferences collection
col, err := app.FindCollectionByNameOrId("preferences")
if err != nil {
t.Fatalf("finding preferences collection: %v", err)
}
if err := app.Delete(col); err != nil {
t.Fatalf("deleting preferences collection: %v", err)
}

err = savePreferenceProfile(app, "test profile")
if err == nil {
t.Error("expected error when preferences collection is missing")
}
}

// ============================================================
// preference.go:127 — countCorrectionsSinceLastProfile entries error
// ============================================================

func TestCountCorrectionsSinceLastProfile_EntriesError(t *testing.T) {
app, cleanup := testutil.NewTestApp(t)
defer cleanup()

// First create a preference record so we exercise the "has profile" path
testutil.CreatePreference(t, app, "test profile", "2024-01-01T00:00:00Z")

// Delete entries collection
entriesCol, err := app.FindCollectionByNameOrId("entries")
if err != nil {
t.Fatalf("finding entries collection: %v", err)
}
if err := app.Delete(entriesCol); err != nil {
t.Fatalf("deleting entries collection: %v", err)
}

_, err = countCorrectionsSinceLastProfile(app)
if err == nil {
t.Error("expected error when entries collection is missing")
}
}
185 changes: 185 additions & 0 deletions internal/ai/preference_coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package ai

import (
"strings"
"testing"

"github.com/jgordijn/knowledgehub/internal/testutil"
"github.com/pocketbase/pocketbase/core"
)

func TestCheckAndRegeneratePreferences_BelowThreshold(t *testing.T) {
app, cleanup := testutil.NewTestApp(t)
defer cleanup()

testutil.CreateSetting(t, app, "openrouter_api_key", "test-key")
testutil.CreateSetting(t, app, "openrouter_model", "test-model")

resource := testutil.CreateResource(t, app, "test", "https://example.com", "rss", "healthy", 0, true)

// Only 2 corrections — below threshold of 20
testutil.CreateEntryWithStars(t, app, resource.Id, "A1", "https://example.com/a1", 3, 5)
testutil.CreateEntryWithStars(t, app, resource.Id, "A2", "https://example.com/a2", 4, 1)

// Should not regenerate
CheckAndRegeneratePreferences(app)

// Verify no profile was created
profiles, _ := app.FindRecordsByFilter("preferences", "1=1", "", 0, 0, nil)
if len(profiles) != 0 {
t.Errorf("expected no profile (below threshold), got %d", len(profiles))
}
}

func TestCheckAndRegeneratePreferences_ExceedsThreshold(t *testing.T) {
app, cleanup := testutil.NewTestApp(t)
defer cleanup()

testutil.CreateSetting(t, app, "openrouter_api_key", "test-key")
testutil.CreateSetting(t, app, "openrouter_model", "test-model")

resource := testutil.CreateResource(t, app, "test", "https://example.com", "rss", "healthy", 0, true)

// Create enough corrections to exceed threshold
for i := 0; i < 21; i++ {
testutil.CreateEntryWithStars(t, app, resource.Id,
"Article "+string(rune('A'+i)),
"https://example.com/"+string(rune('a'+i)),
3, 5)
}

restore := SetCompleteFunc(func(apiKey, model string, messages []Message) (string, error) {
return "User prefers technical articles about programming.", nil
})
defer restore()

CheckAndRegeneratePreferences(app)

profiles, _ := app.FindRecordsByFilter("preferences", "1=1", "", 0, 0, nil)
if len(profiles) != 1 {
t.Fatalf("expected 1 profile, got %d", len(profiles))
}
if profiles[0].GetString("profile_text") != "User prefers technical articles about programming." {
t.Errorf("profile_text = %q", profiles[0].GetString("profile_text"))
}
}

func TestCountCorrectionsSinceLastProfile_WithProfile(t *testing.T) {
app, cleanup := testutil.NewTestApp(t)
defer cleanup()

// Create a profile with a past timestamp
testutil.CreatePreference(t, app, "Old profile", "2024-01-01 00:00:00.000Z")

resource := testutil.CreateResource(t, app, "test", "https://example.com", "rss", "healthy", 0, true)
// These corrections were created "now", which is after the profile
testutil.CreateEntryWithStars(t, app, resource.Id, "A1", "https://example.com/cov-a1", 3, 5)
testutil.CreateEntryWithStars(t, app, resource.Id, "A2", "https://example.com/cov-a2", 4, 1)

count, err := countCorrectionsSinceLastProfile(app)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 2 {
t.Errorf("count = %d, want 2", count)
}
}

func TestSavePreferenceProfile_UpdatesExistingRecord(t *testing.T) {
app, cleanup := testutil.NewTestApp(t)
defer cleanup()

// First save
err := savePreferenceProfile(app, "First profile")
if err != nil {
t.Fatalf("first save error: %v", err)
}

// Second save should update
err = savePreferenceProfile(app, "Updated profile")
if err != nil {
t.Fatalf("second save error: %v", err)
}

profiles, _ := app.FindRecordsByFilter("preferences", "1=1", "", 0, 0, nil)
if len(profiles) != 1 {
t.Fatalf("expected 1 profile (updated), got %d", len(profiles))
}
if profiles[0].GetString("profile_text") != "Updated profile" {
t.Errorf("profile_text = %q, want 'Updated profile'", profiles[0].GetString("profile_text"))
}
}

func TestGeneratePreferenceProfile_Succeeds(t *testing.T) {
app, cleanup := testutil.NewTestApp(t)
defer cleanup()

testutil.CreateSetting(t, app, "openrouter_api_key", "test-key")
testutil.CreateSetting(t, app, "openrouter_model", "test-model")

resource := testutil.CreateResource(t, app, "test", "https://example.com", "rss", "healthy", 0, true)
testutil.CreateEntryWithStars(t, app, resource.Id, "Go Article", "https://example.com/cov-go", 2, 5)
testutil.CreateEntryWithStars(t, app, resource.Id, "JS Article", "https://example.com/cov-js", 5, 1)

restore := SetCompleteFunc(func(apiKey, model string, messages []Message) (string, error) {
return "User strongly prefers Go content over JavaScript.", nil
})
defer restore()

err := GeneratePreferenceProfile(app)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

profiles, _ := app.FindRecordsByFilter("preferences", "1=1", "", 0, 0, nil)
if len(profiles) != 1 {
t.Fatalf("expected 1 profile, got %d", len(profiles))
}
if profiles[0].GetString("profile_text") != "User strongly prefers Go content over JavaScript." {
t.Errorf("profile_text = %q", profiles[0].GetString("profile_text"))
}
}

func TestBuildPreferencePrompt_ContainsCorrections(t *testing.T) {
app, cleanup := testutil.NewTestApp(t)
defer cleanup()

resource := testutil.CreateResource(t, app, "test", "https://example.com", "rss", "healthy", 0, true)
r1 := testutil.CreateEntryWithStars(t, app, resource.Id, "Go Cov Article", "https://example.com/cov-go2", 2, 5)

records := []*core.Record{r1}
prompt := buildPreferencePrompt(records)

if prompt == "" {
t.Fatal("expected non-empty prompt")
}
if !strings.Contains(prompt, "Go Cov Article") {
t.Error("prompt should contain article titles")
}
if !strings.Contains(prompt, "preference profile") {
t.Error("prompt should ask for preference profile")
}
}

func TestTruncateText_Coverage(t *testing.T) {
tests := []struct {
name string
s string
maxLen int
want string
}{
{"short stays", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"gets truncated", "hello world", 5, "hello..."},
{"empty stays empty", "", 10, ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateText(tt.s, tt.maxLen)
if got != tt.want {
t.Errorf("truncateText(%q, %d) = %q, want %q", tt.s, tt.maxLen, got, tt.want)
}
})
}
}
Loading