Files
accounting/internal/api/httpinvoce/controller_test.go
2025-11-04 19:17:50 +01:00

326 lines
12 KiB
Go

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/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, err
}
repos = append(repos, invoice.Repo{
Owner: parts[0],
Repo: parts[1],
})
}
return repos, err
}
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 model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error)
}
func (m *MockInvoiceService) Generate(creditor model.Entity, 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 model.Entity, 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{"owner/repo1"},
},
mockGenerate: func(creditor model.Entity, 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{"owner/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{"owner/repo1"},
},
mockGenerate: func(creditor model.Entity, 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{"owner/repo1"},
},
mockGenerate: func(creditor model.Entity, 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 model.Entity, 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{"owner/repo1"},
},
},
mockGenerate: func(creditor model.Entity, 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{"owner/repo1"},
},
},
mockGenerate: func(creditor model.Entity, 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{"owner/repo1"},
},
},
mockGenerate: func(creditor model.Entity, 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{"owner/repo1"},
},
},
mockGenerate: func(creditor model.Entity, 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)
}
})
}
}