package ideas import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "seo-optimizer/internal/agent" "seo-optimizer/internal/config" "seo-optimizer/internal/reddit" "seo-optimizer/internal/storage" "seo-optimizer/internal/wordpress" "seo-optimizer/pkg/models" ) type fakeLLM struct { postIdeas []*models.PostIdea outreachSuggestions []*models.OutreachSuggestion } func (f *fakeLLM) OptimizePost(ctx context.Context, post *wordpress.Post, internalPosts []*wordpress.InternalPostReference, categories map[int]string, tags map[int]string, options *agent.OptimizeOptions) (*models.OptimizationResponse, error) { return nil, nil } func (f *fakeLLM) GeneratePostIdeas(ctx context.Context, prompt string) ([]*models.PostIdea, error) { if f.postIdeas != nil { return f.postIdeas, nil } return []*models.PostIdea{ { SEOTitle: "Idea SEO", Summary: "Summary", PrimaryKW: "keyword", Sources: []models.IdeaSource{{ Title: "Reddit", URL: "https://reddit.com/r/test", }}, References: []string{"https://example.com"}, }, }, nil } func (f *fakeLLM) GenerateArticleDraft(ctx context.Context, prompt string) (*models.ArticleDraft, error) { return &models.ArticleDraft{ Title: "Draft", SEOTitle: "SEO Draft", MetaDescription: "Meta", Excerpt: "Excerpt", ContentHTML: "
Content
", }, nil } func (f *fakeLLM) GenerateOutreachSuggestions(ctx context.Context, prompt string) ([]*models.OutreachSuggestion, error) { if f.outreachSuggestions != nil { return f.outreachSuggestions, nil } return []*models.OutreachSuggestion{}, nil } func (f *fakeLLM) GenerateNewsletterDraft(ctx context.Context, prompt string) (*models.NewsletterDraft, error) { return nil, nil } func TestGenerateDailyIdeasAndDraftsSpanishOnly(t *testing.T) { // Reddit server redditServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]interface{}{ "data": map[string]interface{}{ "children": []map[string]interface{}{ {"data": map[string]interface{}{ "title": "Post A", "url": "https://reddit.com/r/test/a", "permalink": "/r/test/comments/a", "selftext": "Body", "author": "user", "score": 50, "num_comments": 10, "created_utc": 1710000000, "subreddit": "paternidad", "is_self": true, "stickied": false, }}, }, }, } _ = json.NewEncoder(w).Encode(resp) })) defer redditServer.Close() // WordPress server for style samples wpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/wp-json/wp/v2/posts" { w.WriteHeader(http.StatusNotFound) return } posts := []map[string]interface{}{ { "id": 1, "title": map[string]interface{}{"rendered": "Sample"}, "excerpt": map[string]interface{}{"rendered": "Excerpt"}, "content": map[string]interface{}{"rendered": "Content"}, }, } _ = json.NewEncoder(w).Encode(posts) })) defer wpServer.Close() redditClient := reddit.NewClient(redditServer.URL, nil) wpClient := wordpress.NewClient(wpServer.URL, "user", "pass", 0, nil) ideasStore := storage.NewIdeaStorage(t.TempDir(), nil) if err := ideasStore.Initialize(); err != nil { t.Fatalf("init ideas storage: %v", err) } outreachStore := storage.NewOutreachStorage(t.TempDir(), nil) if err := outreachStore.Initialize(); err != nil { t.Fatalf("init outreach storage: %v", err) } cfg := config.IdeasConfig{ Enabled: true, IntervalHours: 24, Subreddit: "paternidad", Listing: "top", TimeRange: "day", MaxRedditPosts: 5, MaxIdeas: 1, StyleSamplePosts: 1, MinScore: 10, MinComments: 1, BasePath: t.TempDir(), } outreachCfg := config.OutreachConfig{ Enabled: false, } profile := config.SiteProfile{ Name: "Padre Primerizo", URL: "https://padreprimerizo.com", Topics: []string{"Paternidad"}, Audience: "Padres", Voice: "Cercano", } svc := NewService(wpClient, &fakeLLM{}, redditClient, ideasStore, cfg, outreachStore, outreachCfg, profile, []string{"es"}, true, nil) ideas, err := svc.GenerateDailyIdeas(context.Background()) if err != nil { t.Fatalf("GenerateDailyIdeas failed: %v", err) } if len(ideas) != 1 { t.Fatalf("expected 1 idea, got %d", len(ideas)) } idea, err := svc.GenerateDrafts(context.Background(), ideas[0].ID) if err != nil { t.Fatalf("GenerateDrafts failed: %v", err) } if idea.ArticleES == nil { t.Fatalf("expected Spanish draft to be generated") } if idea.ArticleEN != nil { t.Fatalf("expected no English draft for es-only site") } } func TestGenerateOutreachSuggestionsDeduplicatesAndSkipsExistingLinks(t *testing.T) { redditServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]interface{}{ "data": map[string]interface{}{ "children": []map[string]interface{}{ {"data": map[string]interface{}{ "title": "Thread A", "url": "https://www.reddit.com/r/kubernetes/comments/abc/thread_a/", "permalink": "/r/kubernetes/comments/abc/thread_a/", "selftext": "Need help with cluster setup", "author": "user-a", "score": 100, "num_comments": 20, "created_utc": 1710000000, "subreddit": "kubernetes", "is_self": true, "stickied": false, }}, {"data": map[string]interface{}{ "title": "Thread B", "url": "https://www.reddit.com/r/kubernetes/comments/def/thread_b/", "permalink": "/r/kubernetes/comments/def/thread_b/", "selftext": "How to debug networking", "author": "user-b", "score": 120, "num_comments": 30, "created_utc": 1710000500, "subreddit": "kubernetes", "is_self": true, "stickied": false, }}, }, }, } _ = json.NewEncoder(w).Encode(resp) })) defer redditServer.Close() wpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/wp-json/wp/v2/posts" { w.WriteHeader(http.StatusNotFound) return } posts := []map[string]interface{}{ { "id": 10, "slug": "kubernetes-guide", "link": "https://example.com/kubernetes-guide", "title": map[string]interface{}{"rendered": "Kubernetes Guide"}, "excerpt": map[string]interface{}{"rendered": "Guide excerpt"}, "status": "publish", }, } _ = json.NewEncoder(w).Encode(posts) })) defer wpServer.Close() redditClient := reddit.NewClient(redditServer.URL, nil) wpClient := wordpress.NewClient(wpServer.URL, "user", "pass", 0, nil) ideasStore := storage.NewIdeaStorage(t.TempDir(), nil) if err := ideasStore.Initialize(); err != nil { t.Fatalf("init ideas storage: %v", err) } outreachStore := storage.NewOutreachStorage(t.TempDir(), nil) if err := outreachStore.Initialize(); err != nil { t.Fatalf("init outreach storage: %v", err) } // Existing suggestion for thread B should prevent regeneration for the same Reddit URL. if err := outreachStore.Save(&models.OutreachSuggestion{ ID: "existing-b", RedditURL: "https://www.reddit.com/r/kubernetes/comments/def/thread_b/", RedditTitle: "Thread B", ArticleTitle: "Existing Article", ArticleURL: "https://example.com/existing", SuggestedResponse: "Existing response", Status: "new", }); err != nil { t.Fatalf("seed outreach suggestion: %v", err) } ideasCfg := config.IdeasConfig{ Enabled: true, IntervalHours: 24, Subreddit: "kubernetes", Listing: "top", TimeRange: "day", MaxRedditPosts: 5, MaxIdeas: 1, StyleSamplePosts: 1, MinScore: 1, MinComments: 1, BasePath: t.TempDir(), } outreachCfg := config.OutreachConfig{ Enabled: true, Subreddit: "kubernetes", Listing: "top", TimeRange: "day", MaxRedditPosts: 5, MaxSuggestions: 5, MinScore: 1, MinComments: 1, MaxArticles: 10, BasePath: t.TempDir(), } profile := config.SiteProfile{ Name: "Test Site", URL: "https://example.com", Topics: []string{"Kubernetes"}, Audience: "Engineers", Voice: "Technical", } llm := &fakeLLM{ outreachSuggestions: []*models.OutreachSuggestion{ { RedditURL: "https://www.reddit.com/r/kubernetes/comments/abc/thread_a/", RedditTitle: "Thread A", ArticleTitle: "Kubernetes Guide", ArticleURL: "https://example.com/kubernetes-guide", SuggestedResponse: "Helpful answer A.", }, { // Duplicate of Thread A using a different URL shape should be deduplicated. RedditURL: "https://old.reddit.com/r/kubernetes/comments/abc/thread_a/?utm_source=foo", RedditTitle: "Thread A", ArticleTitle: "Kubernetes Guide", ArticleURL: "https://example.com/kubernetes-guide", SuggestedResponse: "Helpful answer A duplicate.", }, { // Existing thread should be skipped. RedditURL: "https://www.reddit.com/r/kubernetes/comments/def/thread_b/", RedditTitle: "Thread B", ArticleTitle: "Kubernetes Guide", ArticleURL: "https://example.com/kubernetes-guide", SuggestedResponse: "Helpful answer B.", }, }, } svc := NewService(wpClient, llm, redditClient, ideasStore, ideasCfg, outreachStore, outreachCfg, profile, []string{"en"}, true, nil) generated, err := svc.GenerateOutreachSuggestions(context.Background()) if err != nil { t.Fatalf("GenerateOutreachSuggestions failed: %v", err) } if len(generated) != 1 { t.Fatalf("expected 1 generated outreach suggestion, got %d", len(generated)) } if generated[0].RedditURL != "https://reddit.com/r/kubernetes/comments/abc/thread_a" { t.Fatalf("unexpected canonical reddit url: %s", generated[0].RedditURL) } all, err := outreachStore.List("all") if err != nil { t.Fatalf("list outreach all: %v", err) } if len(all) != 2 { t.Fatalf("expected 2 total outreach suggestions (seed + new), got %d", len(all)) } }