diff --git a/.gitea/workflows/go.yml b/.gitea/workflows/go.yml index d7df59a..bd19801 100644 --- a/.gitea/workflows/go.yml +++ b/.gitea/workflows/go.yml @@ -15,11 +15,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: "Install webp" - run: | - sudo apt-get update - sudo apt-get install libwebp-dev - - name: Set up Go uses: actions/setup-go@v4 with: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 3ffc82f..72ce73a 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,7 +2,6 @@ name: Release on: push: - branches: ["main"] tags: - "v*" diff --git a/internal/api/httpinvoce/controller_test.go b/internal/api/httpinvoce/controller_test.go new file mode 100644 index 0000000..97e6321 --- /dev/null +++ b/internal/api/httpinvoce/controller_test.go @@ -0,0 +1,326 @@ +package httpinvoce + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.schreifuchs.ch/lou-taylor/accounting/internal/email" + "git.schreifuchs.ch/lou-taylor/accounting/internal/jtype" + "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice" + "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/issue" + "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model" + "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/report" +) + +// Exported versions of the request structs for testing +type InvoiceReq struct { + Debtor model.Entity `json:"debtor"` + Creditor model.Entity `json:"creditor"` + DurationThreshold string `json:"durationThreshold"` // Changed to string + HourlyRate float64 `json:"hourlyRate"` + Repos []string `json:"repositories"` +} + +func (i InvoiceReq) GetRepos() (repos []invoice.Repo, err error) { + for i, repo := range i.Repos { + parts := strings.Split(repo, "/") + if len(parts) != 2 { + err = fmt.Errorf("cannot read body: repo with index %d does not split into 2 parts", i) + return + } + repos = append(repos, invoice.Repo{ + Owner: parts[0], + Repo: parts[1], + }) + } + return +} + +type SendReq struct { + To []string `json:"to"` + Cc []string `json:"cc"` + Bcc []string `json:"bcc"` + Subject string `json:"subject"` + Body string `json:"body"` + Invoice InvoiceReq `json:"invoice"` +} + +func (s SendReq) ToEMail() email.Mail { + return email.Mail{ + To: s.To, + Cc: s.Cc, + Bcc: s.Bcc, + Subject: s.Subject, + Body: s.Body, + } +} + +// MockInvoiceService mocks the invoice.Service interface +type MockInvoiceService struct { + GenerateFunc func(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) +} + +func (m *MockInvoiceService) Generate(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { + if m.GenerateFunc != nil { + return m.GenerateFunc(creditor, debtor, durationThreshold, hourlyRate, repos) + } + return nil, &report.Report{}, nil +} + +// MockEmailService mocks the email.Service interface +type MockEmailService struct { + SendFunc func(mail email.Mail) error +} + +func (m *MockEmailService) Send(mail email.Mail) error { + if m.SendFunc != nil { + return m.SendFunc(mail) + } + return nil +} + +func TestCreateInvoice(t *testing.T) { + // Create a dummy logger for the service + dummyLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + tests := []struct { + name string + requestBody InvoiceReq + mockGenerate func(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) + expectedStatus int + expectedBody string // For error messages or specific content + }{ + { + name: "successful invoice creation", + requestBody: InvoiceReq{ + Creditor: model.Entity{Name: "Creditor"}, + Debtor: model.Entity{Name: "Debtor"}, + DurationThreshold: "1h", // Changed to string + HourlyRate: 100, + Repos: []string{"repo1"}, + }, + mockGenerate: func(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { + pdfContent := "mock PDF content" + // Create a report with a positive total duration + mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, model.Entity{}, 0) + return io.NopCloser(bytes.NewReader([]byte(pdfContent))), mockReport, nil + }, + expectedStatus: http.StatusOK, + expectedBody: "mock PDF content", + }, + { + name: "invalid request body", + requestBody: InvoiceReq{ // Malformed request body + Creditor: model.Entity{Name: "Creditor"}, + Debtor: model.Entity{Name: "Debtor"}, + DurationThreshold: "invalid", // Changed to string + HourlyRate: 100, + Repos: []string{"repo1"}, + }, + mockGenerate: nil, // Not called for invalid body + expectedStatus: http.StatusBadRequest, + expectedBody: "cannot read body", // Partial match for error message + }, + { + name: "invoice generation error", + requestBody: InvoiceReq{ + Creditor: model.Entity{Name: "Creditor"}, + Debtor: model.Entity{Name: "Debtor"}, + DurationThreshold: "1h", // Changed to string + HourlyRate: 100, + Repos: []string{"repo1"}, + }, + mockGenerate: func(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { + return nil, nil, errors.New("failed to generate invoice") + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "internal server error", + }, + { + name: "no suitable issues to be billed", + requestBody: InvoiceReq{ + Creditor: model.Entity{Name: "Creditor"}, + Debtor: model.Entity{Name: "Debtor"}, + DurationThreshold: "1h", // Changed to string + HourlyRate: 100, + Repos: []string{"repo1"}, + }, + mockGenerate: func(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { + // Create a report with zero total duration + mockReport := report.New([]issue.Issue{}, model.Entity{}, model.Entity{}, 0) + return io.NopCloser(bytes.NewReader([]byte("mock PDF content"))), mockReport, nil + }, + expectedStatus: http.StatusNotFound, + expectedBody: "no suitable issues to be billed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockInvoiceService := &MockInvoiceService{ + GenerateFunc: tt.mockGenerate, + } + service := Service{invoice: mockInvoiceService, log: dummyLogger} // Pass the dummy logger + + reqBody, _ := json.Marshal(tt.requestBody) + fmt.Println("Request Body:", string(reqBody)) // Debug print + req := httptest.NewRequest(http.MethodPost, "/invoice", bytes.NewBuffer(reqBody)) + rr := httptest.NewRecorder() + + service.createInvoice(rr, req) + + if rr.Code != tt.expectedStatus { + t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code) + } + + body := rr.Body.String() + if tt.expectedBody != "" && !bytes.Contains([]byte(body), []byte(tt.expectedBody)) { + t.Errorf("expected body to contain %q, got %q", tt.expectedBody, body) + } + }) + } +} + +func TestSendInvoice(t *testing.T) { + // Create a dummy logger for the service + dummyLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + tests := []struct { + name string + requestBody SendReq + mockGenerate func(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) + mockSend func(mail email.Mail) error + expectedStatus int + expectedBody string + }{ + { + name: "successful invoice send", + requestBody: SendReq{ + To: []string{"test@example.com"}, + Subject: "Test Invoice", + Body: "Here is your invoice.", + Invoice: InvoiceReq{ + Creditor: model.Entity{Name: "Creditor"}, + Debtor: model.Entity{Name: "Debtor"}, + DurationThreshold: "1h", // Changed to string + HourlyRate: 100, + Repos: []string{"repo1"}, + }, + }, + mockGenerate: func(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { + pdfContent := "mock PDF content for send" + mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, model.Entity{}, 0) + return io.NopCloser(bytes.NewReader([]byte(pdfContent))), mockReport, nil + }, + mockSend: func(mail email.Mail) error { + return nil // Simulate successful email send + }, + expectedStatus: http.StatusOK, + expectedBody: "mock PDF content for send", + }, + { + name: "invoice generation error during send", + requestBody: SendReq{ + To: []string{"test@example.com"}, + Subject: "Test Invoice", + Body: "Here is your invoice.", + Invoice: InvoiceReq{ + Creditor: model.Entity{Name: "Creditor"}, + Debtor: model.Entity{Name: "Debtor"}, + DurationThreshold: "1h", // Changed to string + HourlyRate: 100, + Repos: []string{"repo1"}, + }, + }, + mockGenerate: func(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { + return nil, nil, errors.New("failed to generate invoice for send") + }, + mockSend: nil, // Not called + expectedStatus: http.StatusInternalServerError, + expectedBody: "error while processing invoice:", + }, + { + name: "email send error", + requestBody: SendReq{ + To: []string{"test@example.com"}, + Subject: "Test Invoice", + Body: "Here is your invoice.", + Invoice: InvoiceReq{ + Creditor: model.Entity{Name: "Creditor"}, + Debtor: model.Entity{Name: "Debtor"}, + DurationThreshold: "1h", // Changed to string + HourlyRate: 100, + Repos: []string{"repo1"}, + }, + }, + mockGenerate: func(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { + pdfContent := "mock PDF content for send" + mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, model.Entity{}, 0) + return io.NopCloser(bytes.NewReader([]byte(pdfContent))), mockReport, nil + }, + mockSend: func(mail email.Mail) error { + return errors.New("failed to send email") + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "error while sending mail:", + }, + { + name: "no suitable issues to be billed during send", + requestBody: SendReq{ + To: []string{"test@example.com"}, + Subject: "Test Invoice", + Body: "Here is your invoice.", + Invoice: InvoiceReq{ + Creditor: model.Entity{Name: "Creditor"}, + Debtor: model.Entity{Name: "Debtor"}, + DurationThreshold: "1h", // Changed to string + HourlyRate: 100, + Repos: []string{"repo1"}, + }, + }, + mockGenerate: func(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { + mockReport := report.New([]issue.Issue{}, model.Entity{}, model.Entity{}, 0) + return io.NopCloser(bytes.NewReader([]byte("mock PDF content"))), mockReport, nil + }, + expectedStatus: http.StatusNotFound, + expectedBody: "no suitable issues to be billed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockInvoiceService := &MockInvoiceService{ + GenerateFunc: tt.mockGenerate, + } + mockEmailService := &MockEmailService{ + SendFunc: tt.mockSend, + } + service := Service{invoice: mockInvoiceService, mail: mockEmailService, log: dummyLogger} // Pass the dummy logger + + reqBody, _ := json.Marshal(tt.requestBody) + fmt.Println("Request Body:", string(reqBody)) // Debug print + req := httptest.NewRequest(http.MethodPost, "/invoice/send", bytes.NewBuffer(reqBody)) + rr := httptest.NewRecorder() + + service.sendInvoice(rr, req) + + if rr.Code != tt.expectedStatus { + t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code) + } + + body := rr.Body.String() + if tt.expectedBody != "" && !bytes.Contains([]byte(body), []byte(tt.expectedBody)) { + t.Errorf("expected body to contain %q, got %q", tt.expectedBody, body) + } + }) + } +}