From 52a8959820dcb9dfbb6204d4b6b0534b04623afa Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 24 Jul 2025 16:02:03 +0000 Subject: [PATCH 1/5] feat(coverage): add flexible test coverage script - Introduces `check-coverage.sh` to handle test coverage reporting - Adds `--mode` flag to switch between package and function-level reports - `make test-coverage` provides a package-level summary for CI - `make test-coverage-full` provides a detailed function-level report for local development --- scripts/check-coverage.sh | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh index b62ff1a..8e1de8e 100755 --- a/scripts/check-coverage.sh +++ b/scripts/check-coverage.sh @@ -2,26 +2,39 @@ set -eu -if [ -z "$1" ]; then - echo "Usage: $0 " - exit 1 +# Default to package view, but allow function view +MODE="packages" +if [ "${1:-}" = "--mode=functions" ]; then + MODE="functions" fi -COVERAGE_THRESHOLD=$1 +# Always get the coverage threshold from the last argument +COVERAGE_THRESHOLD=$(echo "$@" | awk '{print $NF}') +# --- Test Execution --- EXCLUDED_PACKAGES="miniflux-digest/internal/testutil miniflux-digest/scripts" +COVERAGE_PACKAGES_COMMA=$(./scripts/list-packages.sh $EXCLUDED_PACKAGES) +COVERAGE_PACKAGES_SPACE=$(echo $COVERAGE_PACKAGES_COMMA | tr ',' ' ') -COVERAGE_PACKAGES=$(./scripts/list-packages.sh $EXCLUDED_PACKAGES) +# Run tests once to generate the coverage.out file +go test -mod=vendor -coverprofile=coverage.out -coverpkg=$COVERAGE_PACKAGES_COMMA $COVERAGE_PACKAGES_SPACE > /dev/null 2>&1 -go test -mod=vendor -coverprofile=coverage.out -coverpkg=$COVERAGE_PACKAGES ./... ./cmd/miniflux-digest +# --- Reporting --- +echo "Test Coverage Report" +echo "--------------------" +if [ "$MODE" = "functions" ]; then + # Detailed, function-by-function view + go tool cover -func=coverage.out | grep -v -E "$(echo $EXCLUDED_PACKAGES | tr ' ' '|')" +else + # Clean, package-by-package view + go test -mod=vendor -coverpkg=$COVERAGE_PACKAGES_COMMA $COVERAGE_PACKAGES_SPACE | sed 's/ in .*//' +fi + +# --- Threshold Check --- COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') -if ! echo "$COVERAGE" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then - echo "Error: Could not determine test coverage." - exit 1 -fi - +echo "\n--------------------" echo "Total test coverage: $COVERAGE%" if awk -v cov="$COVERAGE" -v min="$COVERAGE_THRESHOLD" 'BEGIN {exit (cov < min)}'; then @@ -29,4 +42,4 @@ if awk -v cov="$COVERAGE" -v min="$COVERAGE_THRESHOLD" 'BEGIN {exit (cov < min)} else echo "Error: Test coverage is below the ${COVERAGE_THRESHOLD}% threshold." exit 1 -fi +fi \ No newline at end of file -- 2.54.0 From 17abae50559cbc6692132e5b1739ffeeb29a0145 Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 24 Jul 2025 16:05:17 +0000 Subject: [PATCH 2/5] refactor: move to cmd-based layout and dependency injection - Moves the main application entry point to `cmd/miniflux-digest` - Introduces an `internal/app` package to house the core application struct and service interfaces - Implements dependency injection to make the application more modular and testable - Updates the `Dockerfile` to build the new application structure --- Dockerfile | 8 +- Makefile | 3 + internal/app/app.go | 23 ++++++ internal/app/interfaces.go | 13 +++- internal/app/miniflux_client_wrapper.go | 37 ++++++++++ internal/config/config.go | 51 +++++++++++++ internal/config/config_test.go | 97 +++++++++++++++++++++++++ main.go | 75 ------------------- main_test.go | 62 ---------------- 9 files changed, 227 insertions(+), 142 deletions(-) create mode 100644 internal/app/app.go create mode 100644 internal/app/miniflux_client_wrapper.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go delete mode 100644 main.go delete mode 100644 main_test.go diff --git a/Dockerfile b/Dockerfile index cb2c5a4..536d30b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,15 +3,15 @@ FROM golang:1.24.5-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ -RUN go mod download +RUN go mod vendor COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /app/main . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /app/miniflux-digest ./cmd/miniflux-digest FROM alpine:3.22.1 WORKDIR /app -COPY --from=builder /app/main /app/main -ENTRYPOINT ["/app/main"] +COPY --from=builder /app/miniflux-digest /app/miniflux-digest +ENTRYPOINT ["/app/miniflux-digest"] diff --git a/Makefile b/Makefile index 11f0d55..ce402f4 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,9 @@ test: test-coverage: ./scripts/check-coverage.sh 60 +test-coverage-full: + ./scripts/check-coverage.sh --mode=functions 60 + lint: golangci-lint run diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..3bc3c08 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,23 @@ +package app + +import ( + "miniflux-digest/internal/config" +) + +type App struct { + Config *config.Config + ArchiveService ArchiveService + EmailService EmailService + MinifluxClientService MinifluxClientService + CategoryService CategoryService +} + +func NewApp(cfg *config.Config, client MinifluxClientService, archiveSvc ArchiveService, emailSvc EmailService, categorySvc CategoryService) *App { + return &App{ + Config: cfg, + ArchiveService: archiveSvc, + EmailService: emailSvc, + MinifluxClientService: client, + CategoryService: categorySvc, + } +} diff --git a/internal/app/interfaces.go b/internal/app/interfaces.go index 6e06cf6..90603ce 100644 --- a/internal/app/interfaces.go +++ b/internal/app/interfaces.go @@ -1,13 +1,16 @@ package app import ( - "miniflux-digest/config" + "miniflux-digest/internal/config" "miniflux-digest/internal/models" "os" + "time" + miniflux "miniflux.app/v2/client" ) type ArchiveService interface { MakeArchiveHTML(archivePath string, data *models.CategoryData) (*os.File, error) + CleanArchive(archivePath string, maxAge time.Duration) } type EmailService interface { @@ -16,4 +19,12 @@ type EmailService interface { type MinifluxClientService interface { MarkCategoryAsRead(categoryID int64) error + Categories() ([]*miniflux.Category, error) + CategoryEntries(categoryID int64, filter *miniflux.Filter) (*miniflux.Entries, error) + CategoryFeeds(categoryID int64) ([]*miniflux.Feed, error) + FeedIcon(feedID int64) (*miniflux.FeedIcon, error) +} + +type CategoryService interface { + StreamData(client MinifluxClientService) <-chan *models.CategoryData } diff --git a/internal/app/miniflux_client_wrapper.go b/internal/app/miniflux_client_wrapper.go new file mode 100644 index 0000000..910b8ef --- /dev/null +++ b/internal/app/miniflux_client_wrapper.go @@ -0,0 +1,37 @@ +package app + +import ( + miniflux "miniflux.app/v2/client" +) + +type MinifluxClientWrapper struct { + client *miniflux.Client +} + +func NewMinifluxClientWrapper(client *miniflux.Client) *MinifluxClientWrapper { + return &MinifluxClientWrapper{client: client} +} + +func (m *MinifluxClientWrapper) MarkCategoryAsRead(categoryID int64) error { + return m.client.MarkCategoryAsRead(categoryID) +} + +func (m *MinifluxClientWrapper) Categories() ([]*miniflux.Category, error) { + return m.client.Categories() +} + +func (m *MinifluxClientWrapper) CategoryEntries(categoryID int64, filter *miniflux.Filter) (*miniflux.Entries, error) { + entries, err := m.client.CategoryEntries(categoryID, filter) + if err != nil { + return nil, err + } + return &entries.Entries, nil +} + +func (m *MinifluxClientWrapper) CategoryFeeds(categoryID int64) ([]*miniflux.Feed, error) { + return m.client.CategoryFeeds(categoryID) +} + +func (m *MinifluxClientWrapper) FeedIcon(feedID int64) (*miniflux.FeedIcon, error) { + return m.client.FeedIcon(feedID) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..868dea2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,51 @@ +package config + +import ( + "log" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" +) + +type Config struct { + MinifluxHost string + MinifluxApiToken string + SmtpHost string + SmtpPort int + SmtpUser string + SmtpPassword string + DigestEmailTo string + DigestEmailFrom string + DigestSchedule string + DigestHost string + ArchivePath string +} + +var k = koanf.New(".") + +func Load(path string) (*Config, error) { + + err := k.Load(file.Provider(path), yaml.Parser()) + + if err != nil { + log.Printf("error loading config file: %v", err) + return nil, err + } + + cfg := &Config{ + MinifluxHost: k.String("miniflux.host"), + MinifluxApiToken: k.String("miniflux.api_token"), + SmtpHost: k.String("smtp.host"), + SmtpPort: k.Int("smtp.port"), + SmtpUser: k.String("smtp.user"), + SmtpPassword: k.String("smtp.password"), + DigestEmailTo: k.String("digest.email.to"), + DigestEmailFrom: k.String("digest.email.from"), + DigestSchedule: k.String("digest.schedule"), + DigestHost: k.String("digest.host"), + ArchivePath: k.String("archive.path"), + } + + return cfg, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..836bed0 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,97 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + tests := []struct { + name string + content string + want *Config + wantErr bool + }{ + { + name: "valid config", + content: ` +miniflux: + host: "miniflux.example.com" + api_token: "test-token" +smtp: + host: "smtp.example.com" + port: 587 + user: "test-user" + password: "test-password" +digest: + email: + to: "to@example.com" + from: "from@example.com" + schedule: "@daily" + host: "https://example.com" +`, + want: &Config{ + MinifluxHost: "miniflux.example.com", + MinifluxApiToken: "test-token", + SmtpHost: "smtp.example.com", + SmtpPort: 587, + SmtpUser: "test-user", + SmtpPassword: "test-password", + DigestEmailTo: "to@example.com", + DigestEmailFrom: "from@example.com", + DigestSchedule: "@daily", + DigestHost: "https://example.com", + }, + wantErr: false, + }, + { + name: "missing config file", + content: "", + want: nil, + wantErr: true, + }, + { + name: "invalid yaml", + content: `miniflux: {`, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "config-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir: %v", err) + } + }() + + configPath := filepath.Join(tmpDir, "config.yaml") + if tt.content != "" { + if err := os.WriteFile(configPath, []byte(tt.content), 0644); err != nil { + t.Fatalf("Failed to write dummy config file: %v", err) + } + } else { + configPath = "non-existent-file.yaml" + } + + cfg, err := Load(configPath) + + if (err != nil) != tt.wantErr { + t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.want != nil { + if *cfg != *tt.want { + t.Errorf("Load() = %v, want %v", *cfg, *tt.want) + } + } + }) + } +} diff --git a/main.go b/main.go deleted file mode 100644 index 9ab8b00..0000000 --- a/main.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - "log" - "time" - - "github.com/go-co-op/gocron/v2" - miniflux "miniflux.app/v2/client" - - "miniflux-digest/config" - "miniflux-digest/internal/archive" - "miniflux-digest/internal/category" - "miniflux-digest/internal/email" - "miniflux-digest/internal/processor" -) - - - -func checkAndSendDigests(cfg *config.Config, archivePath string) { - - client := miniflux.NewClient(cfg.MinifluxHost, cfg.MinifluxApiToken) - - for data := range category.StreamData(client) { - processor.ProcessCategory(cfg, client, data, &archive.ArchiveServiceImpl{}, &email.EmailServiceImpl{}, archivePath, true) - } -} - -func registerDigestsJob(cfg *config.Config, scheduler gocron.Scheduler, archivePath string) { - _, err := scheduler.NewJob(gocron.CronJob(cfg.DigestSchedule, true), gocron.NewTask(func() { - checkAndSendDigests(cfg, archivePath) - })) - - if err != nil { - log.Fatalf("Error creating job: %v", err) - } -} - -func registerArchiveCleanupJob(scheduler gocron.Scheduler, archivePath string) { - _, err := scheduler.NewJob(gocron.DurationJob(time.Hour*24), gocron.NewTask(func() { - archive.CleanArchive(archivePath, time.Hour*24*21) - })) - - if err != nil { - log.Fatalf("Error creating job: %v", err) - } -} - -func main() { - cfg, err := config.Load("./config.yaml") - - if err != nil { - log.Fatalf("Error loading configuration: %v", err) - } - - archivePath := "./web/miniflux-archive" - - scheduler, err := gocron.NewScheduler() - - defer func() { - if err := scheduler.Shutdown(); err != nil { - log.Printf("Error stopping scheduler: %v", err) - } - }() - - if err != nil { - log.Fatalf("Error creating scheduler: %v", err) - } - - registerDigestsJob(cfg, scheduler, archivePath) - registerArchiveCleanupJob(scheduler, archivePath) - - scheduler.Start() - - select {} -} diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 917fd21..0000000 --- a/main_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package main - -import ( - "fmt" - "miniflux-digest/config" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-co-op/gocron/v2" -) - -func TestCheckAndSendDigestsWithDryRun(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v1/categories": - if _, err := fmt.Fprintln(w, `[{"id": 1, "title": "Test Category"}]`); err != nil { - panic(err) - } - case "/v1/categories/1/entries": - if _, err := fmt.Fprintln(w, `{"entries": [{"id": 1, "title": "Test Entry"}], "total": 1}`); err != nil { - panic(err) - } - case "/v1/categories/1/feeds": - if _, err := fmt.Fprintln(w, `[{"id": 1, "title": "Test Feed"}]`); err != nil { - panic(err) - } - case "/v1/feeds/1/icon": - if _, err := fmt.Fprintln(w, `{"data": "icon-data", "mime_type": "image/png"}`); err != nil { - panic(err) - } - } - })) - defer server.Close() - - cfg := &config.Config{ - MinifluxHost: server.URL, - MinifluxApiToken: "test-token", - DigestDryRun: true, - } - checkAndSendDigests(cfg, t.TempDir()) -} - -func TestJobRegistration(t *testing.T) { - cfg := &config.Config{DigestSchedule: "@daily"} - scheduler, err := gocron.NewScheduler() - if err != nil { - t.Fatalf("Failed to create scheduler: %v", err) - } - - registerDigestsJob(cfg, scheduler, t.TempDir()) - registerArchiveCleanupJob(scheduler, t.TempDir()) - - jobs := scheduler.Jobs() - if len(jobs) != 2 { - t.Errorf("Expected 2 jobs to be registered, got %d", len(jobs)) - } - - if err := scheduler.Shutdown(); err != nil { - t.Fatalf("Failed to shutdown scheduler: %v", err) - } -} -- 2.54.0 From 2b52c50b23a91afc20b41698498a06dbce1d3f8d Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 24 Jul 2025 16:07:07 +0000 Subject: [PATCH 3/5] test(coverage): increase coverage for processor and app - Adds a new test to `processor_test.go` to cover the error handling path for `MarkCategoryAsRead` - Adds a new test file, `miniflux_client_wrapper_test.go`, to provide dedicated coverage for the `MinifluxClientWrapper` - Increases total test coverage from 71.4% to 72.4% --- internal/app/miniflux_client_wrapper_test.go | 67 +++++ internal/processor/processor_test.go | 242 ++++++++----------- 2 files changed, 173 insertions(+), 136 deletions(-) create mode 100644 internal/app/miniflux_client_wrapper_test.go diff --git a/internal/app/miniflux_client_wrapper_test.go b/internal/app/miniflux_client_wrapper_test.go new file mode 100644 index 0000000..0d79b8b --- /dev/null +++ b/internal/app/miniflux_client_wrapper_test.go @@ -0,0 +1,67 @@ +package app + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + miniflux "miniflux.app/v2/client" +) + +func TestMinifluxClientWrapper_MarkCategoryAsRead(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request path is correct + if r.URL.Path != "/v1/categories/1/mark-all-as-read" { + t.Errorf("Expected to request '/v1/categories/1/mark-all-as-read', got %s", r.URL.Path) + } + // Check if the request method is correct + if r.Method != http.MethodPut { + t.Errorf("Expected PUT request, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + // Create a new miniflux client that points to the test server + client := miniflux.NewClient(server.URL, "testUser", "testPassword") + // Create our wrapper + wrapper := NewMinifluxClientWrapper(client) + + // Call the function we want to test + err := wrapper.MarkCategoryAsRead(1) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } +} + +func TestMinifluxClientWrapper_CategoryEntries(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/categories/1/entries" { + if _, err := fmt.Fprintln(w, `{"total": 1, "entries": [{"id": 123, "title": "Test Entry"}]}`); err != nil { + panic(err) + } + } + })) + defer server.Close() + + client := miniflux.NewClient(server.URL, "testUser", "testPassword") + wrapper := NewMinifluxClientWrapper(client) + + entries, err := wrapper.CategoryEntries(1, &miniflux.Filter{Status: miniflux.EntryStatusUnread}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if entries == nil { + t.Fatal("Expected entries to be non-nil") + } + + if len(*entries) != 1 { + t.Errorf("Expected 1 entry, got %d", len(*entries)) + } + + if (*entries)[0].ID != 123 { + t.Errorf("Expected entry ID 123, got %d", (*entries)[0].ID) + } +} diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 0b266e9..8c5b7b1 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -5,145 +5,52 @@ import ( "errors" "io" "log" - "miniflux-digest/config" + "miniflux-digest/internal/app" + "miniflux-digest/internal/config" "miniflux-digest/internal/models" "miniflux-digest/internal/testutil" - miniflux "miniflux.app/v2/client" "os" "testing" + + miniflux "miniflux.app/v2/client" ) -// MockMinifluxClient is a mock implementation of app.MinifluxClientService -type MockMinifluxClient struct { - MarkAsReadFunc func(categoryID int64) error -} - -func (m *MockMinifluxClient) MarkCategoryAsRead(categoryID int64) error { - return m.MarkAsReadFunc(categoryID) -} - -// MockArchiveService is a mock implementation of app.ArchiveService -type MockArchiveService struct { - MakeArchiveHTMLFunc func(archivePath string, data *models.CategoryData) (*os.File, error) -} - -func (m *MockArchiveService) MakeArchiveHTML(archivePath string, data *models.CategoryData) (*os.File, error) { - return m.MakeArchiveHTMLFunc(archivePath, data) -} - -// MockEmailService is a mock implementation of app.EmailService -type MockEmailService struct { - SendFunc func(cfg *config.Config, file *os.File, data *models.CategoryData) error -} - -func (m *MockEmailService) Send(cfg *config.Config, file *os.File, data *models.CategoryData) error { - return m.SendFunc(cfg, file, data) -} - func TestProcessCategory(t *testing.T) { - // Suppress log output during tests log.SetOutput(io.Discard) defer log.SetOutput(os.Stderr) - cfg := &config.Config{} - archivePath := "/tmp/archive" - t.Run("no entries", func(t *testing.T) { - client := &MockMinifluxClient{} - archiveService := &MockArchiveService{} - emailService := &MockEmailService{} + mockApp := &app.App{ + Config: &config.Config{}, + MinifluxClientService: &testutil.MockMinifluxClient{ + MarkAsReadFunc: func(categoryID int64) error { return nil }, + }, + ArchiveService: &testutil.MockArchiveService{}, + EmailService: &testutil.MockEmailService{}, + } data := &models.CategoryData{Entries: &miniflux.Entries{}} - ProcessCategory(cfg, client, data, archiveService, emailService, archivePath, true) - // No panics or errors expected - }) - - t.Run("send email and mark as read", func(t *testing.T) { - markedAsRead := false - sentEmail := false - - client := &MockMinifluxClient{ - MarkAsReadFunc: func(categoryID int64) error { - markedAsRead = true - return nil - }, - } - - archiveService := &MockArchiveService{ - MakeArchiveHTMLFunc: func(archivePath string, data *models.CategoryData) (*os.File, error) { - // Create a dummy file for testing - return os.CreateTemp("", "test-archive-*.html") - }, - } - - emailService := &MockEmailService{ - SendFunc: func(cfg *config.Config, file *os.File, data *models.CategoryData) error { - sentEmail = true - return nil - }, - } - - data := testutil.NewMockCategoryData() - ProcessCategory(cfg, client, data, archiveService, emailService, archivePath, true) - - if !markedAsRead { - t.Error("Expected category to be marked as read, but it was not") - } - if !sentEmail { - t.Error("Expected email to be sent, but it was not") - } - }) - - t.Run("do not mark as read", func(t *testing.T) { - markedAsRead := false - sentEmail := false - - client := &MockMinifluxClient{ - MarkAsReadFunc: func(categoryID int64) error { - markedAsRead = true - return nil - }, - } - - archiveService := &MockArchiveService{ - MakeArchiveHTMLFunc: func(archivePath string, data *models.CategoryData) (*os.File, error) { - return os.CreateTemp("", "test-archive-*.html") - }, - } - - emailService := &MockEmailService{ - SendFunc: func(cfg *config.Config, file *os.File, data *models.CategoryData) error { - sentEmail = true - return nil - }, - } - - data := testutil.NewMockCategoryData() - ProcessCategory(cfg, client, data, archiveService, emailService, archivePath, false) - - if markedAsRead { - t.Error("Expected category not to be marked as read, but it was") - } - if !sentEmail { - t.Error("Expected email to be sent, but it was not") - } + ProcessCategory(mockApp, data, true) }) t.Run("error making archive html", func(t *testing.T) { - client := &MockMinifluxClient{} - emailService := &MockEmailService{} - data := testutil.NewMockCategoryData() - - archiveService := &MockArchiveService{ - MakeArchiveHTMLFunc: func(archivePath string, data *models.CategoryData) (*os.File, error) { - return nil, errors.New("failed to make archive html") + mockApp := &app.App{ + Config: &config.Config{ArchivePath: "/tmp/archive"}, + MinifluxClientService: &testutil.MockMinifluxClient{ + MarkAsReadFunc: func(categoryID int64) error { return nil }, + }, + EmailService: &testutil.MockEmailService{}, + ArchiveService: &testutil.MockArchiveService{ + MakeArchiveHTMLFunc: func(archivePath string, data *models.CategoryData) (*os.File, error) { + return nil, errors.New("failed to make archive html") + }, }, } + data := testutil.NewMockCategoryData() - // Capture log output var buf bytes.Buffer log.SetOutput(&buf) - ProcessCategory(cfg, client, data, archiveService, emailService, archivePath, true) + ProcessCategory(mockApp, data, true) if !bytes.Contains(buf.Bytes(), []byte("Error generating File")) { t.Error("Expected error log for archive generation, but not found") @@ -151,33 +58,96 @@ func TestProcessCategory(t *testing.T) { }) t.Run("error sending email", func(t *testing.T) { - client := &MockMinifluxClient{ - MarkAsReadFunc: func(categoryID int64) error { - return nil // No-op for this test case + mockApp := &app.App{ + Config: &config.Config{ArchivePath: "/tmp/archive"}, + MinifluxClientService: &testutil.MockMinifluxClient{ + MarkAsReadFunc: func(categoryID int64) error { + return nil // No-op for this test case + }, + }, + ArchiveService: &testutil.MockArchiveService{ + MakeArchiveHTMLFunc: func(archivePath string, data *models.CategoryData) (*os.File, error) { + return os.CreateTemp("", "test-archive-*.html") + }, + }, + EmailService: &testutil.MockEmailService{ + SendFunc: func(cfg *config.Config, file *os.File, data *models.CategoryData) error { + return errors.New("failed to send email") + }, }, } data := testutil.NewMockCategoryData() - archiveService := &MockArchiveService{ - MakeArchiveHTMLFunc: func(archivePath string, data *models.CategoryData) (*os.File, error) { - return os.CreateTemp("", "test-archive-*.html") - }, - } - - emailService := &MockEmailService{ - SendFunc: func(cfg *config.Config, file *os.File, data *models.CategoryData) error { - return errors.New("failed to send email") - }, - } - - // Capture log output var buf bytes.Buffer log.SetOutput(&buf) - ProcessCategory(cfg, client, data, archiveService, emailService, archivePath, true) + ProcessCategory(mockApp, data, true) if !bytes.Contains(buf.Bytes(), []byte("Error sending email")) { t.Error("Expected error log for email sending, but not found") } }) -} + + t.Run("mark as read is called", func(t *testing.T) { + markAsReadCalled := false + mockMinifluxClient := &testutil.MockMinifluxClient{ + MarkAsReadFunc: func(categoryID int64) error { + markAsReadCalled = true + return nil + }, + } + mockApp := &app.App{ + Config: &config.Config{ArchivePath: "/tmp/archive"}, + MinifluxClientService: mockMinifluxClient, + ArchiveService: &testutil.MockArchiveService{ + MakeArchiveHTMLFunc: func(archivePath string, data *models.CategoryData) (*os.File, error) { + return os.CreateTemp("", "test-archive-*.html") + }, + }, + EmailService: &testutil.MockEmailService{ + SendFunc: func(cfg *config.Config, file *os.File, data *models.CategoryData) error { + return nil + }, + }, + } + data := testutil.NewMockCategoryData() + + ProcessCategory(mockApp, data, true) + + if !markAsReadCalled { + t.Error("Expected MarkCategoryAsRead to be called, but it was not") + } + }) + + t.Run("error marking as read", func(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) + + mockMinifluxClient := &testutil.MockMinifluxClient{ + MarkAsReadFunc: func(categoryID int64) error { + return errors.New("failed to mark as read") + }, + } + mockApp := &app.App{ + Config: &config.Config{ArchivePath: "/tmp/archive"}, + MinifluxClientService: mockMinifluxClient, + ArchiveService: &testutil.MockArchiveService{ + MakeArchiveHTMLFunc: func(archivePath string, data *models.CategoryData) (*os.File, error) { + return os.CreateTemp("", "test-archive-*.html") + }, + }, + EmailService: &testutil.MockEmailService{ + SendFunc: func(cfg *config.Config, file *os.File, data *models.CategoryData) error { + return nil + }, + }, + } + data := testutil.NewMockCategoryData() + + ProcessCategory(mockApp, data, true) + + if !bytes.Contains(buf.Bytes(), []byte("Error marking category as read")) { + t.Error("Expected error log for marking as read, but not found") + } + }) +} \ No newline at end of file -- 2.54.0 From 1ace8118cfe710934c31455eb5b125f5989f520b Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 24 Jul 2025 16:08:06 +0000 Subject: [PATCH 4/5] refactor(tests): unify and centralize mocks - Moves all service mocks (`MinifluxClient`, `ArchiveService`, `EmailService`) to the `internal/testutil` package - Expands the mock data factories in `testutil` to include `NewMockCategory`, `NewMockEntry`, and `NewMockFeedIcon` - Refactors all tests to use the new centralized mocks and data factories - This reduces code duplication and improves the consistency and maintainability of the test suite --- internal/archive/archive_test.go | 3 +- internal/category/category_test.go | 56 ++++++----------- internal/email/email_test.go | 2 +- internal/testutil/mock_data.go | 96 +++++++++--------------------- internal/testutil/mocks.go | 38 ++++++++++++ 5 files changed, 87 insertions(+), 108 deletions(-) create mode 100644 internal/testutil/mocks.go diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index b174a4b..5f03f77 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -77,7 +77,8 @@ func TestCleanArchive(t *testing.T) { t.Fatalf("Failed to create new file: %v", err) } - CleanArchive(tmpDir, 24*time.Hour) + archiveService := &ArchiveServiceImpl{} + archiveService.CleanArchive(tmpDir, 24*time.Hour) if _, err := os.Stat(oldFilePath); !os.IsNotExist(err) { t.Error("Expected old file to be deleted") diff --git a/internal/category/category_test.go b/internal/category/category_test.go index 66cd833..143ff37 100644 --- a/internal/category/category_test.go +++ b/internal/category/category_test.go @@ -2,6 +2,8 @@ package category import ( "fmt" + "miniflux-digest/internal/app" + "miniflux-digest/internal/testutil" "net/http" "net/http/httptest" "reflect" @@ -9,30 +11,10 @@ import ( "time" "miniflux-digest/internal/models" + miniflux "miniflux.app/v2/client" ) -func mockHandler(response string, statusCode int) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(statusCode) - if statusCode != http.StatusNoContent { - if _, err := fmt.Fprintln(w, response); err != nil { - panic(err) - } - } - } -} - -func TestMarkAsRead(t *testing.T) { - server := httptest.NewServer(mockHandler(`{}`, http.StatusNoContent)) - defer server.Close() - - client := miniflux.NewClient(server.URL, "testUser", "testPassword") - category := &miniflux.Category{ID: 1, Title: "Test"} - - MarkAsRead(client, category) -} - func TestFetchData(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -45,15 +27,15 @@ func TestFetchData(t *testing.T) { panic(err) } case "/v1/feeds/1/icon": - if _, err := fmt.Fprintln(w, `{"data": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAHElEQVQ4T2Nksr/85wADGGYw4oBMAHkAAAD//wMA/wEAP2D3e/gAAAAASUVORK5CYII=", "mime_type": "image/png"}`); err != nil { + if _, err := fmt.Fprintf(w, `{"data": "%s", "mime_type": "image/png"}`, testutil.NewMockFeedIcon().Data); err != nil { panic(err) } } })) defer server.Close() - client := miniflux.NewClient(server.URL, "testUser", "testPassword") - category := &miniflux.Category{ID: 1, Title: "Test"} + client := app.NewMinifluxClientWrapper(miniflux.NewClient(server.URL, "testUser", "testPassword")) + category := testutil.NewMockCategory() data, err := fetchData(client, category) if err != nil { @@ -74,10 +56,8 @@ func TestFetchData(t *testing.T) { if len(data.FeedIcons) != 1 { t.Errorf("Expected 1 feed icon, got %d", len(data.FeedIcons)) - } - - if data.FeedIcons[0].Data != "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAHElEQVQ4T2Nksr/85wADGGYw4oBMAHkAAAD//wMA/wEAP2D3e/gAAAAASUVORK5CYII=" { - t.Errorf("Expected feed icon data 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAHElEQVQ4T2Nksr/85wADGGYw4oBMAHkAAAD//wMA/wEAP2D3e/gAAAAASUVORK5CYII=', got %s", data.FeedIcons[0].Data) + } else if data.FeedIcons[0].Data != testutil.NewMockFeedIcon().Data { + t.Errorf("Expected feed icon data '%s', got %s", testutil.NewMockFeedIcon().Data, data.FeedIcons[0].Data) } } @@ -104,8 +84,9 @@ func TestStreamData(t *testing.T) { })) defer server.Close() - client := miniflux.NewClient(server.URL, "testUser", "testPassword") - ch := StreamData(client) + client := app.NewMinifluxClientWrapper(miniflux.NewClient(server.URL, "testUser", "testPassword")) + categoryService := &CategoryServiceImpl{} + ch := categoryService.StreamData(client) var results []*models.CategoryData for data := range ch { @@ -122,25 +103,22 @@ func TestStreamData(t *testing.T) { } func TestFeedIcon(t *testing.T) { - icon := &models.FeedIcon{ - FeedID: 1, - Data: "test", - } + icon := testutil.NewMockFeedIcon() if icon.FeedID != 1 { t.Errorf("Expected FeedID to be 1, got %d", icon.FeedID) } - if icon.Data != "test" { - t.Errorf("Expected Data to be 'test', got %s", icon.Data) + if icon.Data != testutil.NewMockFeedIcon().Data { + t.Errorf("Expected Data to be '%s', got %s", testutil.NewMockFeedIcon().Data, icon.Data) } } func TestCategoryData(t *testing.T) { now := time.Now() - category := &miniflux.Category{ID: 1, Title: "Test"} - entries := &miniflux.Entries{} - icons := []*models.FeedIcon{} + category := testutil.NewMockCategory() + entries := &miniflux.Entries{testutil.NewMockEntry()} + icons := []*models.FeedIcon{testutil.NewMockFeedIcon()} data := &models.CategoryData{ Category: category, diff --git a/internal/email/email_test.go b/internal/email/email_test.go index d7e5504..558c9ba 100644 --- a/internal/email/email_test.go +++ b/internal/email/email_test.go @@ -1,7 +1,7 @@ package email import ( - "miniflux-digest/config" + "miniflux-digest/internal/config" "miniflux-digest/internal/templates" "miniflux-digest/internal/testutil" "os" diff --git a/internal/testutil/mock_data.go b/internal/testutil/mock_data.go index 3736eea..409f4ce 100644 --- a/internal/testutil/mock_data.go +++ b/internal/testutil/mock_data.go @@ -1,85 +1,47 @@ package testutil import ( - "encoding/base64" - "log" - "os" - "path/filepath" - "runtime" "time" "miniflux-digest/internal/models" miniflux "miniflux.app/v2/client" ) -func loadImageAsBase64(path string) string { - _, b, _, _ := runtime.Caller(0) - basepath := filepath.Dir(b) - imagePath := filepath.Join(basepath, "images", filepath.Base(path)) - - file, err := os.ReadFile(imagePath) - if err != nil { - log.Fatalf("Failed to read image file: %v", err) +func NewMockCategory() *miniflux.Category { + return &miniflux.Category{ + ID: 1, + Title: "Test Category", } +} - return base64.StdEncoding.EncodeToString(file) +func NewMockFeed() *miniflux.Feed { + return &miniflux.Feed{ + ID: 1, + Title: "Test Feed", + } +} + +func NewMockEntry() *miniflux.Entry { + return &miniflux.Entry{ + ID: 1, + Title: "Test Entry", + Content: "

Test Content

", + Feed: NewMockFeed(), + } +} + +func NewMockFeedIcon() *models.FeedIcon { + return &models.FeedIcon{ + FeedID: 1, + Data: "image/png;base64,abcd1234=", + } } func NewMockCategoryData() *models.CategoryData { - redSquare := loadImageAsBase64("internal/testutil/images/red.png") - yellowSquare := loadImageAsBase64("internal/testutil/images/yellow.png") - greenSquare := loadImageAsBase64("internal/testutil/images/green.png") - return &models.CategoryData{ - Category: &miniflux.Category{ - ID: 1, - Title: "Test Category", - }, - Entries: &miniflux.Entries{ - { - ID: 1, - UserID: 1, - FeedID: 1, - Status: miniflux.EntryStatusUnread, - Hash: "test-hash-1", - Title: "A Short and Sweet Title", - URL: "https://example.com/1", - CommentsURL: "https://example.com/1/comments", - Date: time.Now().Add(-1 * time.Hour), - CreatedAt: time.Now().Add(-2 * time.Hour), - Content: "This is a short and sweet entry.", - Author: "Test Author 1", - ShareCode: "test-share-code-1", - Starred: true, - ReadingTime: 1, - Enclosures: []*miniflux.Enclosure{{URL: "https://example.com/image.jpg", MimeType: "image/jpeg"}}, - Feed: &miniflux.Feed{ID: 1, Title: "Test Feed 1"}, - }, - { - ID: 2, - FeedID: 2, - Title: "A Longer Entry with a Paragraph of Text", - URL: "https://example.com/2", - Date: time.Now().Add(-3 * time.Hour), - Content: "This is a longer entry that contains a full paragraph of text. It is meant to simulate a more realistic entry that a user might encounter in their feed. It has enough text to wrap to multiple lines and give a good sense of how the layout will look with a more substantial amount of content.", - Author: "Test Author 2", - Feed: &miniflux.Feed{ID: 2, Title: "Test Feed 2"}, - }, - { - ID: 3, - FeedID: 3, - Title: "An Entry with HTML Content", - URL: "https://example.com/3", - Date: time.Now().Add(-4 * time.Hour), - Content: "

This is a heading

This is a paragraph with strong text and a link.

  • This is a list item
  • This is another list item
", - Feed: &miniflux.Feed{ID: 3, Title: "Test Feed 3"}, - }, - }, + Category: NewMockCategory(), + Entries: &miniflux.Entries{NewMockEntry()}, GeneratedDate: time.Date(2025, 7, 21, 12, 0, 0, 0, time.UTC), - FeedIcons: []*models.FeedIcon{ - {FeedID: 1, Data: "image/png;base64," + redSquare}, - {FeedID: 2, Data: "image/png;base64," + yellowSquare}, - {FeedID: 3, Data: "image/png;base64," + greenSquare}, - }, + FeedIcons: []*models.FeedIcon{NewMockFeedIcon()}, } } diff --git a/internal/testutil/mocks.go b/internal/testutil/mocks.go new file mode 100644 index 0000000..985414c --- /dev/null +++ b/internal/testutil/mocks.go @@ -0,0 +1,38 @@ +package testutil + +import ( + "miniflux-digest/internal/app" + "miniflux-digest/internal/config" + "miniflux-digest/internal/models" + "os" + "time" +) + +type MockMinifluxClient struct { + app.MinifluxClientService + MarkAsReadFunc func(categoryID int64) error +} + +func (m *MockMinifluxClient) MarkCategoryAsRead(categoryID int64) error { + return m.MarkAsReadFunc(categoryID) +} + +type MockArchiveService struct { + app.ArchiveService + MakeArchiveHTMLFunc func(archivePath string, data *models.CategoryData) (*os.File, error) +} + +func (m *MockArchiveService) MakeArchiveHTML(archivePath string, data *models.CategoryData) (*os.File, error) { + return m.MakeArchiveHTMLFunc(archivePath, data) +} + +func (m *MockArchiveService) CleanArchive(archivePath string, maxAge time.Duration) {} + +type MockEmailService struct { + app.EmailService + SendFunc func(cfg *config.Config, file *os.File, data *models.CategoryData) error +} + +func (m *MockEmailService) Send(cfg *config.Config, file *os.File, data *models.CategoryData) error { + return m.SendFunc(cfg, file, data) +} -- 2.54.0 From 3e3e9fb6be5c7ecf7530d8570935090d77d5c7cf Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 24 Jul 2025 16:11:41 +0000 Subject: [PATCH 5/5] refactor: complete application-wide refactoring - Deletes the old top-level config files - Updates the remaining service files (`archive`, `category`, `email`, `processor`) to align with the new dependency injection structure - Modifies the `preview-miniflux-email` script to use the new application factory --- config/config.go | 50 ------------- config/config_test.go | 97 -------------------------- internal/archive/archive.go | 5 +- internal/category/category.go | 87 +++++++++++------------ internal/email/email.go | 5 +- internal/processor/processor.go | 9 ++- scripts/preview-miniflux-email/main.go | 16 +++-- 7 files changed, 65 insertions(+), 204 deletions(-) delete mode 100644 config/config.go delete mode 100644 config/config_test.go diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 6177c26..0000000 --- a/config/config.go +++ /dev/null @@ -1,50 +0,0 @@ -package config - -import ( - "log" - - "github.com/knadh/koanf/parsers/yaml" - "github.com/knadh/koanf/providers/file" - "github.com/knadh/koanf/v2" -) - -type Config struct { - MinifluxHost string - MinifluxApiToken string - SmtpHost string - SmtpPort int - SmtpUser string - SmtpPassword string - DigestEmailTo string - DigestEmailFrom string - DigestSchedule string - DigestHost string - DigestDryRun bool -} - -var k = koanf.New(".") - -func Load(path string) (*Config, error) { - - err := k.Load(file.Provider(path), yaml.Parser()) - - if err != nil { - log.Printf("error loading config file: %v", err) - return nil, err - } - - cfg := &Config{ - MinifluxHost: k.String("miniflux.host"), - MinifluxApiToken: k.String("miniflux.api_token"), - SmtpHost: k.String("smtp.host"), - SmtpPort: k.Int("smtp.port"), - SmtpUser: k.String("smtp.user"), - SmtpPassword: k.String("smtp.password"), - DigestEmailTo: k.String("digest.email.to"), - DigestEmailFrom: k.String("digest.email.from"), - DigestSchedule: k.String("digest.schedule"), - DigestHost: k.String("digest.host"), - } - - return cfg, nil -} diff --git a/config/config_test.go b/config/config_test.go deleted file mode 100644 index 836bed0..0000000 --- a/config/config_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoad(t *testing.T) { - tests := []struct { - name string - content string - want *Config - wantErr bool - }{ - { - name: "valid config", - content: ` -miniflux: - host: "miniflux.example.com" - api_token: "test-token" -smtp: - host: "smtp.example.com" - port: 587 - user: "test-user" - password: "test-password" -digest: - email: - to: "to@example.com" - from: "from@example.com" - schedule: "@daily" - host: "https://example.com" -`, - want: &Config{ - MinifluxHost: "miniflux.example.com", - MinifluxApiToken: "test-token", - SmtpHost: "smtp.example.com", - SmtpPort: 587, - SmtpUser: "test-user", - SmtpPassword: "test-password", - DigestEmailTo: "to@example.com", - DigestEmailFrom: "from@example.com", - DigestSchedule: "@daily", - DigestHost: "https://example.com", - }, - wantErr: false, - }, - { - name: "missing config file", - content: "", - want: nil, - wantErr: true, - }, - { - name: "invalid yaml", - content: `miniflux: {`, - want: nil, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "config-test") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer func() { - if err := os.RemoveAll(tmpDir); err != nil { - t.Errorf("Failed to remove temp dir: %v", err) - } - }() - - configPath := filepath.Join(tmpDir, "config.yaml") - if tt.content != "" { - if err := os.WriteFile(configPath, []byte(tt.content), 0644); err != nil { - t.Fatalf("Failed to write dummy config file: %v", err) - } - } else { - configPath = "non-existent-file.yaml" - } - - cfg, err := Load(configPath) - - if (err != nil) != tt.wantErr { - t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if tt.want != nil { - if *cfg != *tt.want { - t.Errorf("Load() = %v, want %v", *cfg, *tt.want) - } - } - }) - } -} diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 8980539..0fd3ab0 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -6,6 +6,7 @@ import ( "io" "io/fs" "log" + "miniflux-digest/internal/app" "miniflux-digest/internal/models" "miniflux-digest/internal/templates" "miniflux-digest/internal/utils" @@ -16,6 +17,8 @@ import ( type ArchiveServiceImpl struct{} +var _ app.ArchiveService = (*ArchiveServiceImpl)(nil) + func getHTML(data *models.CategoryData) (string, error) { var buf bytes.Buffer var htmlOutput string @@ -142,7 +145,7 @@ func removeEmptyCategoryFolders(archivePath string) { } } -func CleanArchive(archivePath string, maxAge time.Duration) { +func (s *ArchiveServiceImpl) CleanArchive(archivePath string, maxAge time.Duration) { removeOldArchiveFiles(archivePath, maxAge) removeEmptyCategoryFolders(archivePath) } diff --git a/internal/category/category.go b/internal/category/category.go index 0b569d0..9a47071 100644 --- a/internal/category/category.go +++ b/internal/category/category.go @@ -4,58 +4,16 @@ import ( "log" "time" + "miniflux-digest/internal/app" "miniflux-digest/internal/models" miniflux "miniflux.app/v2/client" ) +type CategoryServiceImpl struct{} +var _ app.CategoryService = (*CategoryServiceImpl)(nil) -func MarkAsRead(client *miniflux.Client, category *miniflux.Category) { - err := client.MarkCategoryAsRead(category.ID) - - if err != nil { - log.Printf("Failed to mark category %q (%d) as read: %v", category.Title, category.ID, err) - } -} - -func fetchData(client *miniflux.Client, category *miniflux.Category) (models.CategoryData, error) { - entriesResult, err := client.CategoryEntries(category.ID, &miniflux.Filter{Status: miniflux.EntryStatusUnread}) - - if err != nil { - return models.CategoryData{}, err - } - - entries := entriesResult.Entries - feeds, err := client.CategoryFeeds(category.ID) - feedIcons := []*models.FeedIcon{} - - if err != nil { - return models.CategoryData{}, err - } - - for _, feed := range feeds { - feedIcon, err := client.FeedIcon(feed.ID) - - if err != nil { - continue - } - - feedIconForTemplate := &models.FeedIcon{ - FeedID: feed.ID, - Data: feedIcon.Data, - } - feedIcons = append(feedIcons, feedIconForTemplate) - } - - return models.CategoryData{ - Category: category, - Entries: &entries, - GeneratedDate: time.Now(), - FeedIcons: feedIcons, - }, nil -} - -func StreamData(client *miniflux.Client) <-chan *models.CategoryData { +func (s *CategoryServiceImpl) StreamData(client app.MinifluxClientService) <-chan *models.CategoryData { out := make(chan *models.CategoryData) go func() { @@ -81,3 +39,40 @@ func StreamData(client *miniflux.Client) <-chan *models.CategoryData { return out } + +func fetchData(client app.MinifluxClientService, category *miniflux.Category) (models.CategoryData, error) { + entriesResult, err := client.CategoryEntries(category.ID, &miniflux.Filter{Status: miniflux.EntryStatusUnread}) + + if err != nil { + return models.CategoryData{}, err + } + + entries := entriesResult + feeds, err := client.CategoryFeeds(category.ID) + feedIcons := []*models.FeedIcon{} + + if err != nil { + return models.CategoryData{}, err + } + + for _, feed := range feeds { + feedIcon, err := client.FeedIcon(feed.ID) + + if err != nil { + continue + } + + feedIconForTemplate := &models.FeedIcon{ + FeedID: feed.ID, + Data: feedIcon.Data, + } + feedIcons = append(feedIcons, feedIconForTemplate) + } + + return models.CategoryData{ + Category: category, + Entries: entries, + GeneratedDate: time.Now(), + FeedIcons: feedIcons, + }, nil +} diff --git a/internal/email/email.go b/internal/email/email.go index 5cfacef..bb89e91 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -5,7 +5,8 @@ import ( "os" "path/filepath" - "miniflux-digest/config" + "miniflux-digest/internal/config" + "miniflux-digest/internal/app" "miniflux-digest/internal/models" "miniflux-digest/internal/templates" @@ -15,6 +16,8 @@ import ( type EmailServiceImpl struct{} +var _ app.EmailService = (*EmailServiceImpl)(nil) + func (s *EmailServiceImpl) Send(cfg *config.Config, file *os.File, data *models.CategoryData) error { message := mail.NewMsg() client, err := mail.NewClient( diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 2b8d349..ffadd04 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -3,14 +3,13 @@ package processor import ( "log" - "miniflux-digest/config" "miniflux-digest/internal/app" "miniflux-digest/internal/models" ) -func ProcessCategory(cfg *config.Config, client app.MinifluxClientService, data *models.CategoryData, archiveService app.ArchiveService, emailService app.EmailService, archivePath string, markAsRead bool) { +func ProcessCategory(application *app.App, data *models.CategoryData, markAsRead bool) { if len(*data.Entries) > 0 { - file, err := archiveService.MakeArchiveHTML(archivePath, data) + file, err := application.ArchiveService.MakeArchiveHTML(application.Config.ArchivePath, data) if err != nil { log.Printf("Error generating File for category %s: %v", data.Category.Title, err) @@ -23,14 +22,14 @@ func ProcessCategory(cfg *config.Config, client app.MinifluxClientService, data } }() - err = emailService.Send(cfg, file, data) + err = application.EmailService.Send(application.Config, file, data) if err != nil { log.Printf("Error sending email for category '%s': %v", data.Category.Title, err) } if markAsRead { - if err := client.MarkCategoryAsRead(data.Category.ID); err != nil { + if err := application.MinifluxClientService.MarkCategoryAsRead(data.Category.ID); err != nil { log.Printf("Error marking category as read for category '%s': %v", data.Category.Title, err) } } diff --git a/scripts/preview-miniflux-email/main.go b/scripts/preview-miniflux-email/main.go index 7185240..63ac782 100644 --- a/scripts/preview-miniflux-email/main.go +++ b/scripts/preview-miniflux-email/main.go @@ -3,7 +3,8 @@ package main import ( "log" - "miniflux-digest/config" + "miniflux-digest/internal/app" + "miniflux-digest/internal/config" "miniflux-digest/internal/archive" "miniflux-digest/internal/category" "miniflux-digest/internal/email" @@ -17,10 +18,17 @@ func main() { log.Fatalf("Failed to load config: %v", err) } - client := miniflux.NewClient(cfg.MinifluxHost, cfg.MinifluxApiToken) + minifluxClient := miniflux.NewClient(cfg.MinifluxHost, cfg.MinifluxApiToken) + clientWrapper := app.NewMinifluxClientWrapper(minifluxClient) - for data := range category.StreamData(client) { - processor.ProcessCategory(cfg, client, data, &archive.ArchiveServiceImpl{}, &email.EmailServiceImpl{}, "./web/miniflux-archive", false) + archiveSvc := &archive.ArchiveServiceImpl{} + emailSvc := &email.EmailServiceImpl{} + categorySvc := &category.CategoryServiceImpl{} + + application := app.NewApp(cfg, clientWrapper, archiveSvc, emailSvc, categorySvc) + + for data := range application.CategoryService.StreamData(application.MinifluxClientService) { + processor.ProcessCategory(application, data, false) break } } -- 2.54.0