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 = 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{"owner/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{"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, 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, 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{"owner/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{"owner/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{"owner/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{"owner/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) } }) } }