diff --git a/AGENTS.md b/AGENTS.md index 349d378..68d8b11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,22 @@ Alternatively, you can use Docker for a containerized environment: docker-compose up --build ``` +## Writing Tests + +All tests are written using the go standard library and are table driven. +In this application no libraries like testify are used. Mocks are written in +the test file using this schema: + +```go +type Mock struct { + Foo func (string) string +} + +func (m Mock) Foo(in string) string { + return m.Foo(in) +} +``` + ## Running Tests To run all tests for the project, use the following command: diff --git a/go.mod b/go.mod index eb59c38..16393cd 100644 --- a/go.mod +++ b/go.mod @@ -6,22 +6,26 @@ require ( code.gitea.io/sdk/gitea v0.21.0 github.com/caarlos0/env/v10 v10.0.0 github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a + github.com/itzg/go-flagsfiller v1.16.0 github.com/jedib0t/go-pretty/v6 v6.6.8 github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/starwalkn/gotenberg-go-client/v8 v8.11.0 - github.com/itzg/go-flagsfiller v1.16.0 ) require ( github.com/42wim/httpsig v1.2.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/iancoleman/strcase v0.3.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/stretchr/objx v0.5.2 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 30a0ef5..2c08685 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,12 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/starwalkn/gotenberg-go-client/v8 v8.11.0 h1:9hKCcoWegZ0yrZ0tEl5gKlMyH2YraYO0R7zDyL0ZL/U= github.com/starwalkn/gotenberg-go-client/v8 v8.11.0/go.mod h1:5q9nAJ3/lub4hSCT6QYl8cOjnfQ/B+spEzC4TAPVXd8= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -55,5 +59,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/invoice/invoice_test.go b/pkg/invoice/invoice_test.go new file mode 100644 index 0000000..6a536eb --- /dev/null +++ b/pkg/invoice/invoice_test.go @@ -0,0 +1,162 @@ +package invoice + +import ( + "bytes" + "errors" + "io" + "log/slog" + "strings" + "testing" + "time" + + "code.gitea.io/sdk/gitea" + "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model" +) + +// MockGiteaClient is a mock implementation of the GiteaClient interface. +type MockGiteaClient struct { + ListRepoIssuesFunc func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) +} + +func (m *MockGiteaClient) ListRepoIssues(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) { + if m.ListRepoIssuesFunc != nil { + return m.ListRepoIssuesFunc(owner, repo, opt) + } + return nil, nil, errors.New("ListRepoIssuesFunc not implemented") +} + +// MockPdfGenerator is a mock implementation of the PdfGenerator interface. +type MockPdfGenerator struct { + HtmlToPdfFunc func(html string) (io.ReadCloser, error) +} + +func (m *MockPdfGenerator) HtmlToPdf(html string) (io.ReadCloser, error) { + if m.HtmlToPdfFunc != nil { + return m.HtmlToPdfFunc(html) + } + return nil, errors.New("HtmlToPdfFunc not implemented") +} + +func TestGenerate(t *testing.T) { + creditor := model.Entity{ + Name: "creditor", + } + deptor := model.Entity{ + Name: "deptor", + } + rate := 100.0 + repos := []Repo{ + {Owner: "owner", Repo: "repo"}, + } + + testCases := []struct { + name string + setupMocks func(*MockGiteaClient, *MockPdfGenerator) + config *Options + expectedError string + }{ + { + name: "successful generation", + setupMocks: func(g *MockGiteaClient, p *MockPdfGenerator) { + g.ListRepoIssuesFunc = func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) { + return []*gitea.Issue{ + {ID: 1, Title: "Test Issue", Body: "```info\nduration: 1h\n```"}, + }, &gitea.Response{}, nil + } + p.HtmlToPdfFunc = func(html string) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader([]byte("pdf"))), nil + } + }, + config: &DefaultOptions, + expectedError: "", + }, + { + name: "gitea error", + setupMocks: func(g *MockGiteaClient, p *MockPdfGenerator) { + g.ListRepoIssuesFunc = func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) { + return nil, nil, errors.New("gitea error") + } + }, + config: &DefaultOptions, + expectedError: "gitea error", + }, + { + name: "pdf error", + setupMocks: func(g *MockGiteaClient, p *MockPdfGenerator) { + g.ListRepoIssuesFunc = func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) { + return []*gitea.Issue{ + {ID: 1, Title: "Test Issue", Body: "```info\nduration: 1h\n```"}, + }, &gitea.Response{}, nil + } + p.HtmlToPdfFunc = func(html string) (io.ReadCloser, error) { + return nil, errors.New("pdf error") + } + }, + config: &DefaultOptions, + expectedError: "pdf error", + }, + { + name: "no issues", + setupMocks: func(g *MockGiteaClient, p *MockPdfGenerator) { + g.ListRepoIssuesFunc = func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) { + return []*gitea.Issue{}, &gitea.Response{}, nil + } + p.HtmlToPdfFunc = func(html string) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader([]byte("pdf"))), nil + } + }, + config: &DefaultOptions, + expectedError: "", + }, + { + name: "custom template", + setupMocks: func(g *MockGiteaClient, p *MockPdfGenerator) { + g.ListRepoIssuesFunc = func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) { + return []*gitea.Issue{ + {ID: 1, Title: "Test Issue", Body: "```info\nduration: 1h\n```"}, + }, &gitea.Response{}, nil + } + p.HtmlToPdfFunc = func(html string) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader([]byte("pdf"))), nil + } + }, + config: &Options{ + Mindur: time.Minute * 15, + Since: time.Now().AddDate(0, -1, 0), + Before: time.Now(), + IssueState: gitea.StateClosed, + IssueFilter: func(i *gitea.Issue) bool { return true }, + CustomTemplate: "custom template", + }, + expectedError: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + giteaClient := new(MockGiteaClient) + +pdfGenerator := new(MockPdfGenerator) + tc.setupMocks(giteaClient, pdfGenerator) + + service := New(slog.Default(), giteaClient, pdfGenerator) + doc, _, err := service.Generate(creditor, &deptor, rate, repos, tc.config) + + if tc.expectedError != "" { + if err == nil { + t.Fatalf("expected an error, but got none") + } + if !strings.Contains(err.Error(), tc.expectedError) { + t.Errorf("expected error to contain '%s', but got '%s'", tc.expectedError, err.Error()) + } + } else { + if err != nil { + t.Fatalf("expected no error, but got: %v", err) + } + if doc == nil { + t.Fatal("expected a document, but got nil") + } + } + }) + } +} \ No newline at end of file