diff --git a/README.md b/README.md index 31d45c8..8f103d1 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/nobe4/gh-not.svg)](https://pkg.go.dev/github.com/nobe4/gh-not) [![CI](https://github.com/nobe4/gh-not/actions/workflows/ci.yml/badge.svg)](https://github.com/nobe4/gh-not/actions/workflows/ci.yml) +[![CodeQL](https://github.com/nobe4/gh-not/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/nobe4/gh-not/actions/workflows/github-code-scanning/codeql) > [!IMPORTANT] > Under heavy development, expect nothing. diff --git a/internal/api/mock/calls.go b/internal/api/mock/calls.go new file mode 100644 index 0000000..528328d --- /dev/null +++ b/internal/api/mock/calls.go @@ -0,0 +1,64 @@ +package mock + +import ( + "encoding/json" + "io" + "net/http" + "os" + "strings" +) + +type Call struct { + Verb string + Endpoint string + Data any + Error error + Response *http.Response +} + +type RawCall struct { + Verb string `json:"verb"` + Endpoint string `json:"endpoint"` + Data any `json:"data"` + Error error `json:"error"` + RawResponse RawResponse `json:"response"` +} + +type RawResponse struct { + StatusCode int `json:"status_code"` + Body any `json:"body"` +} + +func LoadCallsFromFile(path string) ([]Call, error) { + rawCalls := []RawCall{} + + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + if err = json.Unmarshal(content, &rawCalls); err != nil { + return nil, err + } + + calls := make([]Call, len(rawCalls)) + for i, call := range rawCalls { + body, err := json.Marshal(call.RawResponse.Body) + if err != nil { + return nil, err + } + + calls[i] = Call{ + Verb: call.Verb, + Endpoint: call.Endpoint, + Data: call.Data, + Error: call.Error, + Response: &http.Response{ + StatusCode: call.RawResponse.StatusCode, + Body: io.NopCloser(strings.NewReader(string(body))), + }, + } + } + + return calls, nil +} diff --git a/internal/api/mock/mock.go b/internal/api/mock/mock.go index a94cf12..5fe3539 100644 --- a/internal/api/mock/mock.go +++ b/internal/api/mock/mock.go @@ -3,6 +3,7 @@ package mock import ( "fmt" "io" + "log/slog" "net/http" "github.com/nobe4/gh-not/internal/api" @@ -14,14 +15,6 @@ type Mock struct { index int } -type Call struct { - Verb string - Endpoint string - Data any - Error error - Response *http.Response -} - type MockError struct { verb string endpoint string @@ -36,22 +29,35 @@ func New(c []Call) api.Requestor { return &Mock{Calls: c} } +func (m *Mock) Done() error { + if m.index < len(m.Calls) { + return &MockError{"", "", fmt.Sprintf("%d calls remaining", len(m.Calls)-m.index)} + } + + return nil +} + func (m *Mock) call(verb, endpoint string) (Call, error) { if m.index >= len(m.Calls) { - return Call{}, &MockError{verb, endpoint, "no more calls"} + return Call{}, &MockError{verb, endpoint, "unexpected call: no more calls"} } call := m.Calls[m.index] if (call.Verb != "" && call.Verb != verb) || (call.Endpoint != "" && call.Endpoint != endpoint) { - return Call{}, &MockError{verb, endpoint, "unexpected call"} + return Call{}, &MockError{ + verb, + endpoint, + fmt.Sprintf("unexpected call: mismatch, expected %s %s", call.Verb, call.Endpoint), + } } m.index++ + slog.Debug("mock call", "verb", verb, "endpoint", endpoint, "call", call) return call, nil } -func (m *Mock) Request(verb, endpoint string, body io.Reader) (*http.Response, error) { +func (m *Mock) Request(verb, endpoint string, _ io.Reader) (*http.Response, error) { call, err := m.call(verb, endpoint) if err != nil { return nil, err diff --git a/internal/gh/enrichments.go b/internal/gh/enrichments.go index 836e45b..300f5b5 100644 --- a/internal/gh/enrichments.go +++ b/internal/gh/enrichments.go @@ -20,6 +20,7 @@ func (c *Client) Enrich(n *notifications.Notification) error { return nil } + slog.Debug("enriching", "url", n.Subject.URL) resp, err := c.API.Request(http.MethodGet, n.Subject.URL, nil) if err != nil { return err diff --git a/internal/gh/gh.go b/internal/gh/gh.go index 1a99ebd..11d4c77 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -93,6 +93,7 @@ func nextPageLink(h *http.Header) string { } func (c *Client) request(verb, endpoint string, body io.Reader) ([]*notifications.Notification, string, error) { + slog.Debug("request", "verb", verb, "endpoint", endpoint) response, err := c.API.Request(verb, endpoint, body) if err != nil { return nil, "", err diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 72afc03..9881c4b 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "slices" + "strings" "time" ) @@ -74,6 +75,56 @@ type User struct { Type string `json:"type"` } +func (n Notifications) Equal(others Notifications) bool { + if len(n) != len(others) { + return false + } + + for i, n := range n { + if !n.Equal(others[i]) { + return false + } + } + + return true +} + +func (n Notification) Equal(other *Notification) bool { + return n.Id == other.Id && + n.Unread == other.Unread && + n.Reason == other.Reason && + n.UpdatedAt.Equal(other.UpdatedAt) && + n.URL == other.URL && + n.Repository.Name == other.Repository.Name && + n.Repository.FullName == other.Repository.FullName && + n.Repository.Private == other.Repository.Private && + n.Repository.Fork == other.Repository.Fork && + n.Repository.Owner.Login == other.Repository.Owner.Login && + n.Repository.Owner.Type == other.Repository.Owner.Type && + n.Subject.Title == other.Subject.Title && + n.Subject.URL == other.Subject.URL && + n.Subject.Type == other.Subject.Type && + n.Subject.State == other.Subject.State && + n.Subject.HtmlUrl == other.Subject.HtmlUrl && + n.Author.Login == other.Author.Login && + n.Author.Type == other.Author.Type && + n.Meta.Hidden == other.Meta.Hidden && + n.Meta.Done == other.Meta.Done && + n.Meta.RemoteExists == other.Meta.RemoteExists +} + +func (n Notifications) Debug() string { + out := []string{} + for _, n := range n { + out = append(out, n.Debug()) + } + return strings.Join(out, "\n") +} + +func (n Notification) Debug() string { + return fmt.Sprintf("%#v", n) +} + func (n Notifications) Map() NotificationMap { m := NotificationMap{} for _, n := range n { diff --git a/script/watch-test b/script/watch-test index c1bd450..becd0b4 100755 --- a/script/watch-test +++ b/script/watch-test @@ -1,6 +1,6 @@ #!/usr/bin/env bash -find . -name '*.go' | \ +find . -not -path './.git/*' -not -path './dist/*' | \ entr -c \ bash -c ' go test -cover -coverprofile=coverage ./... && diff --git a/test/integration/000/cache.json b/test/integration/000/cache.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/test/integration/000/cache.json @@ -0,0 +1 @@ +[] diff --git a/test/integration/000/calls.json b/test/integration/000/calls.json new file mode 100644 index 0000000..3deeab5 --- /dev/null +++ b/test/integration/000/calls.json @@ -0,0 +1,10 @@ +[ + { + "verb": "GET", + "endpoint": "https://api.github.com/notifications?all=true", + "response": { + "status_code": 200, + "body": [] + } + } +] diff --git a/test/integration/000/config.yaml b/test/integration/000/config.yaml new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/000/want.json b/test/integration/000/want.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/test/integration/000/want.json @@ -0,0 +1 @@ +[] diff --git a/test/integration/001/cache.json b/test/integration/001/cache.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/test/integration/001/cache.json @@ -0,0 +1 @@ +[] diff --git a/test/integration/001/calls.json b/test/integration/001/calls.json new file mode 100644 index 0000000..1148c2e --- /dev/null +++ b/test/integration/001/calls.json @@ -0,0 +1,27 @@ +[ + { + "verb": "GET", + "endpoint": "https://api.github.com/notifications?all=true", + "response": { + "status_code": 200, + "body": [ + { + "id": "1", + "subject": { + "url": "enrichment#1" + } + } + ] + } + }, + { + "verb": "GET", + "endpoint": "enrichment#1", + "response": { + "status_code": 200, + "body": { + "state": "open" + } + } + } +] diff --git a/test/integration/001/config.yaml b/test/integration/001/config.yaml new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/001/want.json b/test/integration/001/want.json new file mode 100644 index 0000000..8031ae9 --- /dev/null +++ b/test/integration/001/want.json @@ -0,0 +1,12 @@ +[ + { + "id": "1", + "subject": { + "url": "enrichment#1", + "state": "open" + }, + "meta": { + "remote_exists": true + } + } +] diff --git a/test/integration/the_test.go b/test/integration/the_test.go new file mode 100644 index 0000000..cf10061 --- /dev/null +++ b/test/integration/the_test.go @@ -0,0 +1,96 @@ +package tests + +import ( + "encoding/json" + "fmt" + "os" + "testing" + + apiMock "github.com/nobe4/gh-not/internal/api/mock" + configPkg "github.com/nobe4/gh-not/internal/config" + "github.com/nobe4/gh-not/internal/logger" + "github.com/nobe4/gh-not/internal/manager" + "github.com/nobe4/gh-not/internal/notifications" +) + +type config struct { + Id string + // TODO: move those into config so it can be set by default as well as via + // CLI + ForceStrategy manager.ForceStrategy + RefreshStrategy manager.RefreshStrategy +} + +func setup(t *testing.T, conf config) (*manager.Manager, *apiMock.Mock, notifications.Notifications) { + logger.Init(5) + + configPath := fmt.Sprintf("./%s/config.yaml", conf.Id) + callsPath := fmt.Sprintf("./%s/calls.json", conf.Id) + wantPath := fmt.Sprintf("./%s/want.json", conf.Id) + cachePath := fmt.Sprintf("./%s/cache.json", conf.Id) + + c, err := configPkg.New(configPath) + if err != nil { + t.Fatal(err) + } + c.Data.Cache.Path = cachePath + + m := manager.New(c.Data) + + // TODO: move those into config so it can be set by default as well as via + // CLI + m.ForceStrategy = conf.ForceStrategy + m.RefreshStrategy = conf.RefreshStrategy + + calls, err := apiMock.LoadCallsFromFile(callsPath) + caller := &apiMock.Mock{Calls: calls} + m.SetCaller(caller) + + if err := m.Load(); err != nil { + t.Fatal(err) + } + if err := m.Refresh(); err != nil { + t.Fatal(err) + } + + want := notifications.Notifications{} + + raw, err := os.ReadFile(wantPath) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &want); err != nil { + t.Fatal(err) + } + + return m, caller, want +} + +func TestIntegration(t *testing.T) { + dirs, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, dir := range dirs { + if !dir.IsDir() { + continue + } + + t.Run(dir.Name(), func(t *testing.T) { + m, c, want := setup(t, config{ + Id: dir.Name(), + RefreshStrategy: manager.ForceRefresh, + }) + + got := m.Notifications + + if !want.Equal(got) { + t.Fatalf("mismatch notifications\nwant %s\ngot %s", want.Debug(), got.Debug()) + } + + if err := c.Done(); err != nil { + t.Fatal(err) + } + }) + } +}