From 25947b5dd1011d352a1a6c35cf281049c816db49 Mon Sep 17 00:00:00 2001 From: pilyang Date: Mon, 21 Apr 2025 00:01:16 +0900 Subject: [PATCH 1/7] fix: database connection string and add PostingTags helper function --- internal/database/database.go | 2 +- internal/schemasupport/posting_tags.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/database/database.go b/internal/database/database.go index 9fe55d9..9ce0b39 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -11,7 +11,7 @@ import ( func ConnectDatabase(cfg *config.Config) (*ent.Client, error) { pgCfg := cfg.PostgresConfig client, errPg := ent.Open("postgres", fmt.Sprintf("host=%s port=%s user=%s dbname=%s password=%s sslmode=disable", - pgCfg.Host, pgCfg.Port, pgCfg.User, pgCfg.Db, pgCfg.Password)) + pgCfg.Host, pgCfg.Port, pgCfg.User, pgCfg.DB, pgCfg.Password)) if errPg != nil { return nil, errPg diff --git a/internal/schemasupport/posting_tags.go b/internal/schemasupport/posting_tags.go index 5d23e37..e66b160 100644 --- a/internal/schemasupport/posting_tags.go +++ b/internal/schemasupport/posting_tags.go @@ -8,6 +8,12 @@ import ( type PostingTags []string +// NewPostingTags creates a new PostingTags from a string slice +func NewPostingTags(tags []string) *PostingTags { + pt := PostingTags(tags) + return &pt +} + func (t *PostingTags) Scan(value interface{}) error { if value == nil { *t = []string{} From 1bee54ab04ad3a43342ddaa16174b40cdf977801 Mon Sep 17 00:00:00 2001 From: pilyang Date: Mon, 21 Apr 2025 00:01:45 +0900 Subject: [PATCH 2/7] feat: add RSS feed fetching and posting creation functionality --- internal/http/handler/rsshandler.go | 69 ++++++++++++ internal/rss/rss_parser.go | 135 ++++++++++++++++++++++++ internal/rss/rss_parser_test.go | 158 ++++++++++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 internal/http/handler/rsshandler.go create mode 100644 internal/rss/rss_parser.go create mode 100644 internal/rss/rss_parser_test.go diff --git a/internal/http/handler/rsshandler.go b/internal/http/handler/rsshandler.go new file mode 100644 index 0000000..b11ea05 --- /dev/null +++ b/internal/http/handler/rsshandler.go @@ -0,0 +1,69 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/techbloghub/server/ent" + "github.com/techbloghub/server/internal/rss" +) + +type RssSyncResponse struct { + Status string `json:"status"` + Count int `json:"count"` + CompanyID int `json:"company_id,omitempty"` + Message string `json:"message,omitempty"` +} + +// SyncCompanyRSSFeed syncs postings from a specific company's RSS feed +func SyncCompanyRSSFeed(client *ent.Client) gin.HandlerFunc { + return func(c *gin.Context) { + companyID, err := strconv.Atoi(c.Param("company_id")) + if err != nil { + c.JSON(http.StatusBadRequest, RssSyncResponse{ + Status: "error", + Message: "Invalid company ID", + }) + return + } + + count, err := rss.SyncCompanyRSSFeed(c.Request.Context(), client, companyID) + if err != nil { + c.JSON(http.StatusInternalServerError, RssSyncResponse{ + Status: "error", + CompanyID: companyID, + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, RssSyncResponse{ + Status: "success", + Count: count, + CompanyID: companyID, + Message: "RSS feed sync completed successfully", + }) + } +} + +// SyncAllRSSFeeds syncs postings from all companies with RSS feeds +func SyncAllRSSFeeds(client *ent.Client) gin.HandlerFunc { + return func(c *gin.Context) { + count, err := rss.SyncAllCompanyFeeds(c.Request.Context(), client) + if err != nil { + c.JSON(http.StatusInternalServerError, RssSyncResponse{ + Status: "error", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, RssSyncResponse{ + Status: "success", + Count: count, + Message: "All RSS feeds synced successfully", + }) + } +} + diff --git a/internal/rss/rss_parser.go b/internal/rss/rss_parser.go new file mode 100644 index 0000000..ebf1340 --- /dev/null +++ b/internal/rss/rss_parser.go @@ -0,0 +1,135 @@ +package rss + +import ( + "context" + "fmt" + "log" + "net/url" + "time" + + "github.com/mmcdole/gofeed" + "github.com/techbloghub/server/ent" + "github.com/techbloghub/server/ent/posting" + "github.com/techbloghub/server/internal/schemasupport" +) + +// FetchRSSItems fetches RSS items from a given URL +func FetchRSSItems(rssURL *url.URL) ([]*gofeed.Item, error) { + parser := gofeed.NewParser() + feed, err := parser.ParseURL(rssURL.String()) + if err != nil { + return nil, fmt.Errorf("failed to parse RSS feed: %w", err) + } + + return feed.Items, nil +} + +// ProcessRSSItem converts an RSS item to a posting create input +func ProcessRSSItem(ctx context.Context, client *ent.Client, item *gofeed.Item, companyID int) (*ent.PostingCreate, error) { + // Skip items without links + if item.Link == "" { + return nil, fmt.Errorf("RSS item has no link") + } + + // Parse URL + postURL, err := url.Parse(item.Link) + if err != nil { + return nil, fmt.Errorf("failed to parse item URL: %w", err) + } + + // Get published date or use current time if not available + publishedAt := time.Now() + if item.PublishedParsed != nil { + publishedAt = *item.PublishedParsed + } + + // Create the posting + postingCreate := client.Posting.Create(). + SetTitle(item.Title). + SetURL(postURL). + SetCompanyID(companyID). + SetPublishedAt(publishedAt) + + // Add tags if available + if item.Categories != nil && len(item.Categories) > 0 { + postingCreate.SetTags(schemasupport.NewPostingTags(item.Categories)) + } + + return postingCreate, nil +} + +// SyncCompanyRSSFeed syncs postings from a company's RSS feed +func SyncCompanyRSSFeed(ctx context.Context, client *ent.Client, companyID int) (int, error) { + // Get the company with its RSS URL + company, err := client.Company.Get(ctx, companyID) + if err != nil { + return 0, fmt.Errorf("failed to find company: %w", err) + } + + // Check if the company has an RSS URL + if company.RssURL == nil { + return 0, fmt.Errorf("company does not have an RSS URL") + } + + // Fetch postings from RSS feed + items, err := FetchRSSItems(company.RssURL) + if err != nil { + return 0, err + } + + // Process items and save to database + count := 0 + for _, item := range items { + postingCreate, err := ProcessRSSItem(ctx, client, item, companyID) + if err != nil { + log.Printf("Failed to process RSS item: %v", err) + continue + } + + // Check if posting already exists by URL + urlValue, _ := postingCreate.Mutation().URL() + exists, err := client.Posting.Query(). + Where(posting.URLEQ(urlValue)). + Exist(ctx) + if err != nil { + log.Printf("Failed to check if posting exists: %v", err) + continue + } + + // Only save if posting doesn't exist + if !exists { + _, err = postingCreate.Save(ctx) + if err != nil { + log.Printf("Failed to save posting: %v", err) + continue + } + count++ + } + } + + return count, nil +} + +// SyncAllCompanyFeeds syncs RSS feeds for all companies with RSS URLs +func SyncAllCompanyFeeds(ctx context.Context, client *ent.Client) (int, error) { + // Get all companies that have RSS URLs + companies, err := client.Company.Query().All(ctx) + if err != nil { + return 0, fmt.Errorf("failed to query companies: %w", err) + } + + totalCount := 0 + for _, company := range companies { + if company.RssURL != nil { + count, err := SyncCompanyRSSFeed(ctx, client, company.ID) + if err != nil { + log.Printf("Failed to sync RSS feed for company %s: %v", company.Name, err) + continue + } + totalCount += count + log.Printf("Synced %d new postings for %s", count, company.Name) + } + } + + return totalCount, nil +} diff --git a/internal/rss/rss_parser_test.go b/internal/rss/rss_parser_test.go new file mode 100644 index 0000000..b68c005 --- /dev/null +++ b/internal/rss/rss_parser_test.go @@ -0,0 +1,158 @@ +package rss_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/techbloghub/server/ent" + "github.com/techbloghub/server/internal/rss" + "github.com/techbloghub/server/internal/testutils" +) + +func TestSyncCompanyRSSFeed(t *testing.T) { + testutils.TransactionalTest(t, func(t *testing.T, client *ent.Client) { + ctx := context.Background() + + // Test with invalid RSS URL + t.Run("Company with empty RSS URL", func(t *testing.T) { + // Create test company with empty RSS URL + emptyURL := &url.URL{} + company, err := client.Company.Create(). + SetName("Empty RSS Company"). + SetBlogURL(&url.URL{Scheme: "https", Host: "example.com"}). + SetLogoURL(&url.URL{Scheme: "https", Host: "example.com", Path: "/logo.png"}). + SetRssURL(emptyURL). + Save(ctx) + assert.NoError(t, err) + + // Try to sync RSS feed (should fail due to invalid URL) + count, err := rss.SyncCompanyRSSFeed(ctx, client, company.ID) + assert.Error(t, err) + assert.Equal(t, 0, count) + assert.Contains(t, err.Error(), "failed to parse") + }) + + // Setup a mock RSS server + server := setupMockRSSServer() + defer server.Close() + + t.Run("Company with RSS URL", func(t *testing.T) { + // Create test company with RSS URL + serverURL, _ := url.Parse(server.URL + "/feed.xml") + company, err := client.Company.Create(). + SetName("RSS Company"). + SetBlogURL(&url.URL{Scheme: "https", Host: "example.com"}). + SetLogoURL(&url.URL{Scheme: "https", Host: "example.com", Path: "/logo.png"}). + SetRssURL(serverURL). + Save(ctx) + assert.NoError(t, err) + + // Sync RSS feed + count, err := rss.SyncCompanyRSSFeed(ctx, client, company.ID) + assert.NoError(t, err) + assert.Equal(t, 2, count) // We expect 2 postings from our mock RSS feed + + // Verify postings were created + postings, err := client.Company.QueryPostings(company).All(ctx) + assert.NoError(t, err) + assert.Len(t, postings, 2) + + // Verify posting content + assert.Equal(t, "Test Post 1", postings[0].Title) + assert.Equal(t, "Test Post 2", postings[1].Title) + }) + }) +} + +func TestSyncAllCompanyFeeds(t *testing.T) { + testutils.TransactionalTest(t, func(t *testing.T, client *ent.Client) { + ctx := context.Background() + + // Setup a mock RSS server + server := setupMockRSSServer() + defer server.Close() + + // Create test companies + serverURL, _ := url.Parse(server.URL + "/feed.xml") + + // Company with RSS URL + _, err := client.Company.Create(). + SetName("RSS Company 1"). + SetBlogURL(&url.URL{Scheme: "https", Host: "example1.com"}). + SetLogoURL(&url.URL{Scheme: "https", Host: "example1.com", Path: "/logo1.png"}). + SetRssURL(serverURL). + Save(ctx) + assert.NoError(t, err) + + // Another company with same RSS URL (to test deduplication) + _, err = client.Company.Create(). + SetName("RSS Company 2"). + SetBlogURL(&url.URL{Scheme: "https", Host: "example2.com"}). + SetLogoURL(&url.URL{Scheme: "https", Host: "example2.com", Path: "/logo2.png"}). + SetRssURL(serverURL). + Save(ctx) + assert.NoError(t, err) + + // Company with empty RSS URL (won't be synced) + _, err = client.Company.Create(). + SetName("No RSS Company"). + SetBlogURL(&url.URL{Scheme: "https", Host: "example3.com"}). + SetLogoURL(&url.URL{Scheme: "https", Host: "example3.com", Path: "/logo3.png"}). + SetRssURL(&url.URL{}). + Save(ctx) + assert.NoError(t, err) + + // Sync all RSS feeds + count, err := rss.SyncAllCompanyFeeds(ctx, client) + assert.NoError(t, err) + assert.Equal(t, 2, count) // 2 postings from first company, 0 from second (deduplication) + + // Verify total postings + postingsCount, err := client.Posting.Query().Count(ctx) + assert.NoError(t, err) + assert.Equal(t, 2, postingsCount) + }) +} + +// Setup a mock RSS server +func setupMockRSSServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return a simple RSS feed with two items + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + + // Current time for published dates + now := time.Now().Format(time.RFC1123Z) + + // Simple RSS feed + feed := ` + + + Test Blog + https://example.com/blog + A blog for testing RSS feeds + + Test Post 1 + https://example.com/blog/post1 + ` + now + ` + golang + testing + + + Test Post 2 + https://example.com/blog/post2 + ` + now + ` + rss + + +` + + w.Write([]byte(feed)) + })) +} + From abbf5cbd5b1650f99540094da10aa3e1e4c979db Mon Sep 17 00:00:00 2001 From: pilyang Date: Mon, 21 Apr 2025 00:02:00 +0900 Subject: [PATCH 3/7] feat: add scheduler for periodic RSS feed syncing --- internal/scheduler/scheduler.go | 81 +++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 internal/scheduler/scheduler.go diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..7962219 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,81 @@ +package scheduler + +import ( + "context" + "log" + "time" + + "github.com/robfig/cron/v3" + "github.com/techbloghub/server/config" + "github.com/techbloghub/server/ent" + "github.com/techbloghub/server/internal/rss" +) + +// Scheduler manages all scheduled tasks +type Scheduler struct { + client *ent.Client + cron *cron.Cron + config *config.SchedulerConfig +} + +// NewScheduler creates a new scheduler +func NewScheduler(client *ent.Client, config *config.SchedulerConfig) *Scheduler { + // Create a new cron scheduler with seconds field enabled + c := cron.New(cron.WithSeconds()) + return &Scheduler{ + client: client, + cron: c, + config: config, + } +} + +// Start starts the scheduler +func (s *Scheduler) Start() { + s.setupJobs() + s.cron.Start() + log.Println("Scheduler started") +} + +// Stop stops the scheduler +func (s *Scheduler) Stop() { + ctx := s.cron.Stop() + <-ctx.Done() + log.Println("Scheduler stopped") +} + +// setupJobs sets up all scheduled jobs +func (s *Scheduler) setupJobs() { + // Use the cron expression from config or fall back to default (hourly) + cronExpr := s.config.RssSyncIntervalCron + if cronExpr == "" { + cronExpr = "0 0 * * * *" // Default: every hour + } + + // Add RSS sync job with configured cron expression + _, err := s.cron.AddFunc(cronExpr, func() { + s.syncRSSFeeds() + }) + if err != nil { + log.Printf("Failed to add RSS sync job: %v", err) + } else { + log.Printf("RSS sync job scheduled with cron expression: %s", cronExpr) + } +} + +// syncRSSFeeds syncs all company RSS feeds +func (s *Scheduler) syncRSSFeeds() { + log.Println("Starting RSS feed sync...") + + // Create a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + count, err := rss.SyncAllCompanyFeeds(ctx, s.client) + if err != nil { + log.Printf("Error syncing RSS feeds: %v", err) + return + } + + log.Printf("RSS feed sync completed. Added %d new postings", count) +} + From c02e86feadbf79ebbcfbda32cb693f31ff200c4a Mon Sep 17 00:00:00 2001 From: pilyang Date: Mon, 21 Apr 2025 00:02:24 +0900 Subject: [PATCH 4/7] feat: integrate RSS fetching functionality into main application --- cmd/main.go | 11 +++++++++ config/config.go | 43 ++++++++++++++++++++++++++++++---- internal/http/router/router.go | 13 ++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 59376b5..e047b9e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -10,6 +10,7 @@ import ( _ "github.com/techbloghub/server/ent/runtime" "github.com/techbloghub/server/internal/database" "github.com/techbloghub/server/internal/http/router" + "github.com/techbloghub/server/internal/scheduler" "github.com/gin-gonic/gin" _ "github.com/lib/pq" @@ -32,6 +33,16 @@ func main() { } defer dbClient.Close() + // Initialize and start the scheduler if enabled + if cfg.SchedulerConfig.Enabled { + sched := scheduler.NewScheduler(dbClient, &cfg.SchedulerConfig) + sched.Start() + defer sched.Stop() + log.Println("RSS feed scheduler started") + } else { + log.Println("RSS feed scheduler disabled") + } + if err := r.Run(":" + cfg.ServerConfig.Port); err != nil { log.Fatalf("Error while running server: %v", err) } diff --git a/config/config.go b/config/config.go index fae477e..9468ad3 100644 --- a/config/config.go +++ b/config/config.go @@ -3,11 +3,13 @@ package config import ( "fmt" "os" + "strconv" ) type Config struct { PostgresConfig ServerConfig + SchedulerConfig } type PostgresConfig struct { @@ -15,7 +17,7 @@ type PostgresConfig struct { Port string User string Password string - Db string + DB string } type ServerConfig struct { @@ -23,13 +25,18 @@ type ServerConfig struct { Env string } +type SchedulerConfig struct { + Enabled bool + RssSyncIntervalCron string +} + func (cfg *PostgresConfig) ToMap() map[string]string { return map[string]string{ "HOST": cfg.Host, "PORT": cfg.Port, "USER": cfg.User, "PASSWORD": cfg.Password, - "DB": cfg.Db, + "DB": cfg.DB, } } @@ -40,13 +47,20 @@ func (cfg *ServerConfig) ToMap() map[string]string { } } +func (cfg *SchedulerConfig) ToMap() map[string]string { + return map[string]string{ + "ENABLED": strconv.FormatBool(cfg.Enabled), + "RSS_SYNC_INTERVAL_CRON": cfg.RssSyncIntervalCron, + } +} + func NewConfig() (*Config, error) { postgresConf := PostgresConfig{ Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), User: os.Getenv("POSTGRES_USER"), Password: os.Getenv("POSTGRES_PASSWORD"), - Db: os.Getenv("POSTGRES_DB"), + DB: os.Getenv("POSTGRES_DB"), } serverConf := ServerConfig{ @@ -54,9 +68,27 @@ func NewConfig() (*Config, error) { Env: os.Getenv("ENV"), } + // Default scheduler configuration + schedulerEnabled := true + if enabled, err := strconv.ParseBool(os.Getenv("SCHEDULER_ENABLED")); err == nil { + schedulerEnabled = enabled + } + + // Default to every hour if not specified + rssSyncCron := "0 0 * * * *" + if cronExpr := os.Getenv("RSS_SYNC_INTERVAL_CRON"); cronExpr != "" { + rssSyncCron = cronExpr + } + + schedulerConf := SchedulerConfig{ + Enabled: schedulerEnabled, + RssSyncIntervalCron: rssSyncCron, + } + cfg := &Config{ - PostgresConfig: postgresConf, - ServerConfig: serverConf, + PostgresConfig: postgresConf, + ServerConfig: serverConf, + SchedulerConfig: schedulerConf, } if err := validateEnvs(cfg); err != nil { @@ -71,6 +103,7 @@ func validateEnvs(cfg *Config) error { missingEnvs = append(missingEnvs, findEmptyValueKeys(cfg.PostgresConfig.ToMap())...) missingEnvs = append(missingEnvs, findEmptyValueKeys(cfg.ServerConfig.ToMap())...) + // Scheduler has defaults, so we don't check for missing values if len(missingEnvs) > 0 { return fmt.Errorf("missing envs: %v", missingEnvs) diff --git a/internal/http/router/router.go b/internal/http/router/router.go index 0ccf82a..ac4e289 100644 --- a/internal/http/router/router.go +++ b/internal/http/router/router.go @@ -20,4 +20,17 @@ func InitRouter(r *gin.Engine, client *ent.Client) { // 포스팅(게시글 조회) r.GET("/postings", handler.GetPostings(client)) + + // RSS feed sync endpoints + // These would typically be protected by authentication/authorization in production + rssGroup := r.Group("/rss") + { + // Sync all company RSS feeds + // curl -X POST http://localhost:8080/rss/sync + rssGroup.POST("/sync", handler.SyncAllRSSFeeds(client)) + + // Sync a specific company's RSS feed + // curl -X POST http://localhost:8080/rss/sync/company/1 + rssGroup.POST("/sync/company/:company_id", handler.SyncCompanyRSSFeed(client)) + } } From 4448532a67028f7042a1f532b8209cbc028c5734 Mon Sep 17 00:00:00 2001 From: pilyang Date: Mon, 21 Apr 2025 00:02:39 +0900 Subject: [PATCH 5/7] chore: add dependencies for RSS parsing and scheduling --- go.mod | 7 ++++++- go.sum | 33 +++++++++++++++++++-------------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index d26d3a9..0e1fcfb 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,20 @@ go 1.23.4 require ( entgo.io/ent v0.14.1 + github.com/gin-contrib/cors v1.7.3 github.com/gin-gonic/gin v1.10.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 + github.com/mmcdole/gofeed v1.3.0 + github.com/robfig/cron/v3 v3.0.1 github.com/stretchr/testify v1.10.0 ) require ( ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect + github.com/PuerkitoBio/goquery v1.8.0 // indirect github.com/agext/levenshtein v1.2.1 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/bytedance/sonic v1.12.6 // indirect github.com/bytedance/sonic/loader v0.2.1 // indirect @@ -20,7 +25,6 @@ require ( github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.7 // indirect - github.com/gin-contrib/cors v1.7.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-openapi/inflect v0.19.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -35,6 +39,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect + github.com/mmcdole/goxpp v1.1.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect diff --git a/go.sum b/go.sum index 27d9e1f..9556cb2 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,12 @@ entgo.io/ent v0.14.1 h1:fUERL506Pqr92EPHJqr8EYxbPioflJo6PudkrEA8a/s= entgo.io/ent v0.14.1/go.mod h1:MH6XLG0KXpkcDQhKiHfANZSzR55TJyPL5IGNpI8wpco= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= +github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk= @@ -60,9 +64,9 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -75,29 +79,29 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8= +github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -125,29 +129,30 @@ golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= -golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 061faba28ac536171630a9702891f3f3ae8f0676 Mon Sep 17 00:00:00 2001 From: pilyang Date: Mon, 21 Apr 2025 00:02:52 +0900 Subject: [PATCH 6/7] docs: update CLAUDE.md with testing guidance and architecture info --- CLAUDE.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4382ba1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands +- **Build**: `go build -o techbloghub-server ./cmd/main.go` +- **Run Tests**: `go test ./...` +- **Single Test**: `go test -v ./path/to/package -run TestName` +- **Start Dev DB**: `./script/dev-db.sh` +- **Start Test DB**: `./script/test-db.sh` +- **Create Migration**: `./script/create_migration.sh migration_name` +- **Stop Test Containers**: `docker stop test-tbh-postgres` + +## Code Style +- **Imports**: Standard library first, third-party next, internal packages last +- **Error Handling**: Always check errors with `if err != nil`, return errors up the call stack +- **Naming**: camelCase for private, PascalCase for exported; lowercase package names +- **Testing**: Use testify for assertions, TransactionalTest for DB tests +- **Structure**: HTTP handlers in internal/http/handler/, DB operations with EntGo ORM +- **Comments**: Document public functions, Korean comments acceptable +- **Formatting**: Always run `go fmt ./...` before committing + +## Architecture +- HTTP routing via Gin framework +- Database access via EntGo ORM +- Soft delete via SoftDeleteMixin +- Pagination support via TechbloghubPaging +- RSS feed processing with gofeed parser + +## Testing +1. **Database Test Setup**: + - Start test database: `./script/test-db.sh` + - Clean up after tests: `docker stop test-tbh-postgres` + +2. **Running Tests**: + - Run specific package tests: `go test -v ./internal/package/...` + - Run all tests: `go test ./...` + +3. **Test Structure**: + - Use `testutils.TransactionalTest` for database tests + - Create test data using the EntGo client + - When testing entities, always include all required fields + - Remember to set all required fields when creating test data (e.g., Company requires name, logo_url, blog_url) + +4. **Test Assertions**: + - Use `assert.NoError()` to check for successful operations + - Use `assert.Error()` to verify expected errors + - Use `assert.Equal()` to compare expected vs. actual values + - Use `assert.Contains()` to check error message contents From 0a0507a71836f362e77ee5ed70a586ecc0354fcb Mon Sep 17 00:00:00 2001 From: pilyang Date: Mon, 21 Apr 2025 00:03:19 +0900 Subject: [PATCH 7/7] fix: update database field reference in test utils --- internal/testutils/db.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/testutils/db.go b/internal/testutils/db.go index 0fd1539..c064ef7 100644 --- a/internal/testutils/db.go +++ b/internal/testutils/db.go @@ -21,7 +21,7 @@ func SetupDB(t *testing.T) (*ent.Client, *ent.Tx) { // enttest: client 생성 & migration 실행 client := enttest.Open(t, "postgres", fmt.Sprintf("host=%s port=%s user=%s dbname=%s password=%s sslmode=disable", - pgCfg.Host, pgCfg.Port, pgCfg.User, pgCfg.Db, pgCfg.Password)) + pgCfg.Host, pgCfg.Port, pgCfg.User, pgCfg.DB, pgCfg.Password)) // transaction 시작 tx, err := client.Tx(context.Background())