336 lines
12 KiB
Go
336 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, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, *report.Report, error)
|
|
}
|
|
|
|
func (m *MockInvoiceService) Generate(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, *report.Report, error) {
|
|
if m.GenerateFunc != nil {
|
|
return m.GenerateFunc(creditor, debtor, hourlyRate, repos, opts)
|
|
}
|
|
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: func(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, *report.Report, error) {
|
|
if opts == nil {
|
|
opts = &invoice.DefaultOptions
|
|
}
|
|
return tt.mockGenerate(creditor, debtor, opts.Mindur, hourlyRate, repos)
|
|
},
|
|
}
|
|
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: func(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, *report.Report, error) {
|
|
if opts == nil {
|
|
opts = &invoice.DefaultOptions
|
|
}
|
|
return tt.mockGenerate(creditor, debtor, opts.Mindur, hourlyRate, repos)
|
|
},
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|