332 lines
10 KiB
Go
332 lines
10 KiB
Go
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: "<p>Content</p>",
|
|
}, 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))
|
|
}
|
|
}
|