package services import ( "net/http" "net/http/httptest" "strings" "testing" "time" ) func TestTranslateQueryFallsBackToGoogleWithoutGeminiKey(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(`[[["rainy city street","비 오는 도시 거리",null,null,1]],null,"ko"]`)) })) defer server.Close() service := NewGeminiService("") service.Client = &http.Client{Timeout: 2 * time.Second} service.TranslateEndpoint = server.URL translated := service.TranslateQuery("비 오는 도시 거리") if translated != "rainy city street" { t.Fatalf("expected google fallback translation, got %q", translated) } } func TestTranslateQueryFallsBackToDictionaryWhenTranslateFails(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusBadGateway) })) defer server.Close() service := NewGeminiService("") service.Client = &http.Client{Timeout: 2 * time.Second} service.TranslateEndpoint = server.URL translated := service.TranslateQuery("숲속 커플") if translated != "forest couple" { t.Fatalf("expected dictionary fallback translation, got %q", translated) } } func TestTranslateSummaryToKoreanUsesGoogleAndCaches(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[[["도시에서 웃는 커플","smiling couple in city",null,null,1]],null,"en"]`)) })) defer server.Close() service := NewGeminiService("") service.Client = &http.Client{Timeout: 2 * time.Second} service.TranslateEndpoint = server.URL first, err := service.TranslateSummaryToKorean("smiling couple in city") if err != nil { t.Fatalf("expected translation to succeed, got error: %v", err) } second, err := service.TranslateSummaryToKorean("smiling couple in city") if err != nil { t.Fatalf("expected cached translation to succeed, got error: %v", err) } if first != "도시에서 웃는 커플" || second != first { t.Fatalf("unexpected translated summary values: %q %q", first, second) } if requests != 1 { t.Fatalf("expected one upstream translation request due to cache, got %d", requests) } } func TestNormalizeKnownMediaPhrases(t *testing.T) { translated := translateKoreanMediaTerms("사이버 펑크 도시") if translated != "cyberpunk city" { t.Fatalf("expected cyberpunk city, got %q", translated) } } func TestBuildSupplementalQueriesReturnsGeneratedLines(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(`{"candidates":[{"content":{"parts":[{"text":"authentic couple city walk\ncandid couple park footage\nnatural lifestyle b-roll"}]}}]}`)) })) defer server.Close() service := NewGeminiService("dummy-key") service.Client = &http.Client{Timeout: 2 * time.Second} service.GenerateEndpoint = server.URL queries, err := service.BuildSupplementalQueries("다정한 커플", []string{"friendly couple"}, []AIRecommendation{ {Assessment: "irrelevant", SearchHint: "authentic lifestyle couple"}, }) if err != nil { t.Fatalf("expected supplemental query generation to succeed, got %v", err) } if len(queries) < 3 || queries[0] != "authentic couple city walk" { t.Fatalf("unexpected supplemental queries: %#v", queries) } } func TestSelectUnevaluatedCandidatesSkipsReviewedLinks(t *testing.T) { ranked := []SearchResult{ {Link: "https://a.example"}, {Link: "https://b.example"}, {Link: "https://c.example"}, } reviewed := map[string]bool{ "https://a.example": true, } selected := SelectUnevaluatedCandidates(ranked, reviewed, 2) if len(selected) != 2 { t.Fatalf("expected 2 selected candidates, got %d", len(selected)) } if selected[0].Link != "https://b.example" || selected[1].Link != "https://c.example" { t.Fatalf("unexpected selection order: %#v", selected) } } func TestRemainingGeminiCapacityShrinksWithReviewedItems(t *testing.T) { reviewed := []AIRecommendation{ {Link: "https://a.example"}, {Link: "https://b.example"}, } if got := RemainingGeminiCapacity(reviewed); got != 22 { t.Fatalf("expected 22 remaining slots, got %d", got) } } func TestGeminiVisualCacheRoundTrip(t *testing.T) { service := NewGeminiService("") service.setCachedVisual("image\nhttps://example.com/thumb.jpg", "abc", "image/jpeg", time.Minute) data, mimeType, ok := service.getCachedVisual("image\nhttps://example.com/thumb.jpg") if !ok { t.Fatal("expected visual cache hit") } if data != "abc" || mimeType != "image/jpeg" { t.Fatalf("unexpected cached visual data: %q %q", data, mimeType) } } func TestGeminiTranslationCacheRoundTrip(t *testing.T) { service := NewGeminiService("") service.setCachedTranslation("비 오는 도시", "rainy city", time.Minute) value, ok := service.getCachedTranslation("비 오는 도시") if !ok { t.Fatal("expected translation cache hit") } if value != "rainy city" { t.Fatalf("unexpected translation cache value: %q", value) } } func TestGeminiExpansionCacheRoundTrip(t *testing.T) { service := NewGeminiService("") service.setCachedExpansion("city rain", []string{"city rain", "city rain stock footage"}, time.Minute) value, ok := service.getCachedExpansion("city rain") if !ok { t.Fatal("expected expansion cache hit") } if len(value) != 2 || value[1] != "city rain stock footage" { t.Fatalf("unexpected expansion cache value: %#v", value) } } func TestDecorateRecommendationMediaUsesEmbedForGoogleVideo(t *testing.T) { item := DecorateRecommendationMedia(AIRecommendation{ Source: "Google Video", Link: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", ThumbnailURL: "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", }) if item.MediaMode != "thumbnail" { t.Fatalf("expected thumbnail media mode, got %q", item.MediaMode) } if item.EmbedURL == "" || !strings.Contains(item.EmbedURL, "youtube-nocookie.com/embed/") { t.Fatalf("unexpected embed url: %q", item.EmbedURL) } if item.ActionType != "download" || item.ActionLabel != "Direct Download" { t.Fatalf("unexpected Google Video actions: %#v", item) } } func TestRankSearchResultsPrefersUsableVisuals(t *testing.T) { results := []SearchResult{ {Title: "cyberpunk city", Link: "https://example.com/a", ThumbnailURL: "https://example.com/favicon.ico"}, {Title: "cyberpunk city", Link: "https://example.com/b", ThumbnailURL: "https://example.com/frame.jpg"}, } ranked := RankSearchResults("cyberpunk city", results) if ranked[0].Link != "https://example.com/b" { t.Fatalf("expected usable thumbnail result first, got %#v", ranked) } } func TestMergeRecommendationsExcludesIrrelevantAndPendingFiller(t *testing.T) { recommended := []AIRecommendation{ {Title: "keep", Link: "https://a.example", Recommended: true, Assessment: "positive", ThumbnailURL: "https://example.com/a.jpg"}, {Title: "drop", Link: "https://b.example", Recommended: false, Assessment: "irrelevant", ThumbnailURL: "https://example.com/b.jpg", Reason: "관련이 없습니다."}, } ranked := []SearchResult{ {Title: "keep", Link: "https://a.example", ThumbnailURL: "https://example.com/a.jpg"}, {Title: "extra", Link: "https://c.example", ThumbnailURL: "https://example.com/c.jpg"}, } merged := MergeRecommendations(recommended, ranked, 16) if len(merged) != 1 { t.Fatalf("expected only the positive recommendation without pending filler, got %#v", merged) } if merged[0].Link != "https://a.example" { t.Fatalf("unexpected merged result: %#v", merged) } } func TestFilterHardGeminiErrorsIgnoresLowValueVisualFailures(t *testing.T) { errs := []string{ "candidate thumbnail is low value", "no candidate thumbnails or preview frames could be fetched for gemini vision", "gemini vision JSON extraction failed: no complete JSON object found", } filtered := filterHardGeminiErrors(errs) if len(filtered) != 1 { t.Fatalf("expected only hard errors to remain, got %#v", filtered) } if !strings.Contains(filtered[0], "JSON extraction failed") { t.Fatalf("unexpected filtered errors: %#v", filtered) } } func TestParseGeminiVisionRecommendationsRecoversCompleteObjectsFromTruncatedJSON(t *testing.T) { raw := "{\n" + " \"recommendations\": [\n" + " {\"index\":0,\"verdict\":\"Yes\",\"reason\":\"적합\",\"recommended\":true,\"assessment\":\"positive\",\"searchHint\":\"\"},\n" + " {\"index\":1,\"verdict\":\"No\",\"reason\":\"부적합\",\"recommended\":false,\"assessment\":\"irrelevant\",\"searchHint\":\"night city b-roll\"},\n" + " {\"index\":2,\"verdict\":\"Yes\",\"reason\":\"잘림" parsed, recoveredPartial, err := parseGeminiVisionRecommendations(raw) if err != nil { t.Fatalf("expected partial recovery, got error: %v", err) } if !recoveredPartial { t.Fatal("expected partial recovery flag to be true") } if len(parsed.Recommendations) != 2 { t.Fatalf("expected 2 recovered recommendation objects, got %#v", parsed.Recommendations) } if parsed.Recommendations[0].Index != 0 || parsed.Recommendations[1].Index != 1 { t.Fatalf("unexpected recovered recommendations: %#v", parsed.Recommendations) } } func TestExtractCompleteRecommendationObjectsReturnsNilWhenArrayMissing(t *testing.T) { if got := extractCompleteRecommendationObjects(`{"message":"no recommendations here"}`); len(got) != 0 { t.Fatalf("expected no objects, got %#v", got) } } func TestGeminiVisionMaxOutputTokensShrinksForSingleCandidate(t *testing.T) { if got := geminiVisionMaxOutputTokens(1); got != 180 { t.Fatalf("expected 180 tokens for single candidate, got %d", got) } if got := geminiVisionMaxOutputTokens(4); got != 420 { t.Fatalf("expected 420 tokens for four candidates, got %d", got) } }