diff --git a/bin/install_tools.sh b/bin/install_tools.sh index 6d730bb..714f6c1 100755 --- a/bin/install_tools.sh +++ b/bin/install_tools.sh @@ -5,6 +5,7 @@ set -o errexit -eo pipefail cd ./internal/tools go install \ - github.com/golangci/golangci-lint/cmd/golangci-lint + github.com/golangci/golangci-lint/cmd/golangci-lint \ + github.com/maxbrunsfeld/counterfeiter/v6 cd ../.. diff --git a/go.mod b/go.mod index 2fc933b..82d6b20 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,17 @@ module gitea.dwysokinski.me/Kichiyaki/notificationarr go 1.18 -require github.com/stretchr/testify v1.8.0 +require ( + github.com/go-chi/chi/v5 v5.0.7 + github.com/google/go-cmp v0.5.8 + github.com/google/uuid v1.3.0 + github.com/stretchr/testify v1.8.0 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5164829..e60ef53 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,17 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= +github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -8,8 +19,9 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -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/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/internal/domain/errors.go b/internal/domain/errors.go index 7c36c6a..98862c7 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -6,10 +6,16 @@ type ErrorCode uint8 const ( ErrorCodeUnknown ErrorCode = iota + ErrorCodeValidation + ErrorCodeIO // External I/O error such as network failure. ) func (e ErrorCode) String() string { switch e { + case ErrorCodeValidation: + return "validation-error" + case ErrorCodeIO: + return "io-error" case ErrorCodeUnknown: fallthrough default: diff --git a/internal/domain/errors_test.go b/internal/domain/errors_test.go index 371caec..3955fab 100644 --- a/internal/domain/errors_test.go +++ b/internal/domain/errors_test.go @@ -13,6 +13,8 @@ func TestErrorCode_String(t *testing.T) { t.Parallel() assert.Equal(t, domain.ErrorCodeUnknown.String(), "internal-server-error") + assert.Equal(t, domain.ErrorCodeValidation.String(), "validation-error") + assert.Equal(t, domain.ErrorCodeIO.String(), "io-error") } func TestError(t *testing.T) { @@ -25,7 +27,7 @@ func TestError(t *testing.T) { expectedMsg string }{ { - name: "OK: WithCode, WithMessage", + name: "OK: WithCode(domain.ErrorCodeUnknown), WithMessage", options: []domain.ErrorOption{ domain.WithCode(domain.ErrorCodeUnknown), domain.WithMessage("err err"), @@ -35,7 +37,27 @@ func TestError(t *testing.T) { expectedMsg: "err err", }, { - name: "OK: WithCode, WithMessagef, WithErr", + name: "OK: WithCode(domain.ErrorCodeValidation), WithMessage", + options: []domain.ErrorOption{ + domain.WithCode(domain.ErrorCodeValidation), + domain.WithMessage("err err"), + }, + expectedCode: domain.ErrorCodeValidation, + expectedErr: nil, + expectedMsg: "err err", + }, + { + name: "OK: WithCode(domain.ErrorCodeIO), WithMessage", + options: []domain.ErrorOption{ + domain.WithCode(domain.ErrorCodeIO), + domain.WithMessage("err err"), + }, + expectedCode: domain.ErrorCodeIO, + expectedErr: nil, + expectedMsg: "err err", + }, + { + name: "OK: WithCode(domain.ErrorCodeUnknown), WithMessagef, WithErr", options: []domain.ErrorOption{ domain.WithCode(domain.ErrorCodeUnknown), domain.WithMessagef("xxx %d", 25), diff --git a/internal/domain/sonarr_webhook_payload.go b/internal/domain/sonarr_webhook_payload.go new file mode 100644 index 0000000..23b10f5 --- /dev/null +++ b/internal/domain/sonarr_webhook_payload.go @@ -0,0 +1,45 @@ +package domain + +type SonarrEventType string + +const ( + SonarrEventTypeDownload SonarrEventType = "Download" + SonarrEventTypeTest SonarrEventType = "Test" +) + +var ( + ErrUnsupportedSonarrEventType = NewError(WithCode(ErrorCodeValidation), WithMessage("unsupported event type")) +) + +func NewSonarrEventType(s string) (SonarrEventType, error) { + conv := SonarrEventType(s) + switch conv { + case SonarrEventTypeDownload, + SonarrEventTypeTest: + return conv, nil + default: + return "", ErrUnsupportedSonarrEventType + } +} + +func (s SonarrEventType) String() string { + return string(s) +} + +type SonarrSeries struct { + ID int64 + Title string +} + +type SonarrEpisode struct { + ID int64 + EpisodeNumber int16 + SeasonNumber int16 + Title string +} + +type SonarrWebhookPayload struct { + EventType SonarrEventType + Series SonarrSeries + Episodes []SonarrEpisode +} diff --git a/internal/domain/sonarr_webhook_payload_test.go b/internal/domain/sonarr_webhook_payload_test.go new file mode 100644 index 0000000..5027bfd --- /dev/null +++ b/internal/domain/sonarr_webhook_payload_test.go @@ -0,0 +1,33 @@ +package domain_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" +) + +func TestNewSonarrEventType(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + events := []domain.SonarrEventType{domain.SonarrEventTypeDownload} + + for _, ev := range events { + res, err := domain.NewSonarrEventType(ev.String()) + assert.Equal(t, ev, res) + assert.NoError(t, err) + } + }) + + t.Run("ERR: invalid event type", func(t *testing.T) { + events := []string{"test1", "test2", "aaaa", "bbb"} + + for _, ev := range events { + res, err := domain.NewSonarrEventType(ev) + assert.Zero(t, res) + assert.ErrorIs(t, err, domain.ErrUnsupportedSonarrEventType) + } + }) +} diff --git a/internal/rest/internal/mock/sonarr_service.gen.go b/internal/rest/internal/mock/sonarr_service.gen.go new file mode 100644 index 0000000..9bcf83a --- /dev/null +++ b/internal/rest/internal/mock/sonarr_service.gen.go @@ -0,0 +1,115 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package mock + +import ( + "context" + "sync" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/rest" +) + +type FakeSonarrService struct { + ProcessStub func(context.Context, domain.SonarrWebhookPayload) error + processMutex sync.RWMutex + processArgsForCall []struct { + arg1 context.Context + arg2 domain.SonarrWebhookPayload + } + processReturns struct { + result1 error + } + processReturnsOnCall map[int]struct { + result1 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeSonarrService) Process(arg1 context.Context, arg2 domain.SonarrWebhookPayload) error { + fake.processMutex.Lock() + ret, specificReturn := fake.processReturnsOnCall[len(fake.processArgsForCall)] + fake.processArgsForCall = append(fake.processArgsForCall, struct { + arg1 context.Context + arg2 domain.SonarrWebhookPayload + }{arg1, arg2}) + stub := fake.ProcessStub + fakeReturns := fake.processReturns + fake.recordInvocation("Process", []interface{}{arg1, arg2}) + fake.processMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeSonarrService) ProcessCallCount() int { + fake.processMutex.RLock() + defer fake.processMutex.RUnlock() + return len(fake.processArgsForCall) +} + +func (fake *FakeSonarrService) ProcessCalls(stub func(context.Context, domain.SonarrWebhookPayload) error) { + fake.processMutex.Lock() + defer fake.processMutex.Unlock() + fake.ProcessStub = stub +} + +func (fake *FakeSonarrService) ProcessArgsForCall(i int) (context.Context, domain.SonarrWebhookPayload) { + fake.processMutex.RLock() + defer fake.processMutex.RUnlock() + argsForCall := fake.processArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeSonarrService) ProcessReturns(result1 error) { + fake.processMutex.Lock() + defer fake.processMutex.Unlock() + fake.ProcessStub = nil + fake.processReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeSonarrService) ProcessReturnsOnCall(i int, result1 error) { + fake.processMutex.Lock() + defer fake.processMutex.Unlock() + fake.ProcessStub = nil + if fake.processReturnsOnCall == nil { + fake.processReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.processReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeSonarrService) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.processMutex.RLock() + defer fake.processMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeSonarrService) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ rest.SonarrService = new(FakeSonarrService) diff --git a/internal/rest/rest.go b/internal/rest/rest.go new file mode 100644 index 0000000..380d1f9 --- /dev/null +++ b/internal/rest/rest.go @@ -0,0 +1,56 @@ +package rest + +import ( + "encoding/json" + "errors" + "net/http" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" +) + +//go:generate counterfeiter -generate + +var ( + errInternal = domain.NewError( + domain.WithCode(domain.ErrorCodeUnknown), + domain.WithMessage("internal server error"), + ) +) + +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` +} +type ErrorResponse struct { + Error APIError `json:"error"` +} + +func renderErrorResponse(w http.ResponseWriter, err error) { + var convError domain.Error + if !errors.As(err, &convError) { + convError = errInternal + } + renderJSON(w, errorCodeToHTTPStatus(convError.Code()), ErrorResponse{ + Error: APIError{ + Code: convError.Code().String(), + Message: convError.Message(), + }, + }) +} +func errorCodeToHTTPStatus(code domain.ErrorCode) int { + switch code { + case domain.ErrorCodeValidation: + return http.StatusBadRequest + case domain.ErrorCodeIO: + return http.StatusServiceUnavailable + case domain.ErrorCodeUnknown: + fallthrough + default: + return http.StatusInternalServerError + } +} +func renderJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} diff --git a/internal/rest/rest_test.go b/internal/rest/rest_test.go new file mode 100644 index 0000000..577ab7a --- /dev/null +++ b/internal/rest/rest_test.go @@ -0,0 +1,33 @@ +package rest_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/assert" +) + +func doRequest(mux chi.Router, method, target string, body io.Reader) *http.Response { + rr := httptest.NewRecorder() + req := httptest.NewRequest(method, target, body) + mux.ServeHTTP(rr, req) + return rr.Result() +} + +func assertJSONResponse(tb testing.TB, resp *http.Response, expectedStatus int, expected any, target any) { + tb.Helper() + + assert.Equal(tb, expectedStatus, resp.StatusCode) + assert.NoError(tb, json.NewDecoder(resp.Body).Decode(target)) + opts := cmp.Options{ + cmpopts.IgnoreUnexported(time.Time{}), + } + assert.True(tb, cmp.Equal(expected, target, opts...), cmp.Diff(expected, target, opts...)) +} diff --git a/internal/rest/webhook_handler.go b/internal/rest/webhook_handler.go new file mode 100644 index 0000000..d11db43 --- /dev/null +++ b/internal/rest/webhook_handler.go @@ -0,0 +1,92 @@ +package rest + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" + + "github.com/go-chi/chi/v5" +) + +//counterfeiter:generate -o internal/mock/sonarr_service.gen.go . SonarrService +type SonarrService interface { + Process(ctx context.Context, payload domain.SonarrWebhookPayload) error +} + +type WebhookHandler struct { + sonarrSvc SonarrService +} + +func NewWebhookHandler(sonarrSvc SonarrService) *WebhookHandler { + return &WebhookHandler{ + sonarrSvc: sonarrSvc, + } +} + +func (h *WebhookHandler) Register(r chi.Router) { + r.Post("/webhook/sonarr", h.handleSonarrWebhook) +} + +type SonarrSeries struct { + ID int64 `json:"id"` + Title string `json:"title"` +} + +type SonarrEpisode struct { + ID int64 `json:"id"` + EpisodeNumber int16 `json:"episodeNumber"` + SeasonNumber int16 `json:"seasonNumber"` + Title string `json:"title"` +} + +type SonarrWebhookRequest struct { + EventType string `json:"eventType"` + Series SonarrSeries `json:"series"` + Episodes []SonarrEpisode `json:"episodes"` +} + +func (h *WebhookHandler) handleSonarrWebhook(w http.ResponseWriter, r *http.Request) { + var req SonarrWebhookRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + renderErrorResponse(w, domain.NewError( + domain.WithErr(err), + domain.WithCode(domain.ErrorCodeValidation), + domain.WithMessage("invalid request body"), + )) + return + } + + eventType, err := domain.NewSonarrEventType(req.EventType) + if err != nil { + renderErrorResponse(w, fmt.Errorf("domain.NewSonarrEventType: %w", err)) + return + } + + payload := domain.SonarrWebhookPayload{ + EventType: eventType, + Series: domain.SonarrSeries{ + ID: req.Series.ID, + Title: req.Series.Title, + }, + Episodes: make([]domain.SonarrEpisode, 0, len(req.Episodes)), + } + + for _, ep := range req.Episodes { + payload.Episodes = append(payload.Episodes, domain.SonarrEpisode{ + ID: ep.ID, + EpisodeNumber: ep.EpisodeNumber, + SeasonNumber: ep.SeasonNumber, + Title: ep.Title, + }) + } + + if err := h.sonarrSvc.Process(r.Context(), payload); err != nil { + renderErrorResponse(w, fmt.Errorf("SonarrService.Process: %w", err)) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/rest/webhook_handler_test.go b/internal/rest/webhook_handler_test.go new file mode 100644 index 0000000..bcc641b --- /dev/null +++ b/internal/rest/webhook_handler_test.go @@ -0,0 +1,131 @@ +package rest_test + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "io" + "net/http" + "testing" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" + + "github.com/stretchr/testify/assert" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/rest" + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/rest/internal/mock" + "github.com/go-chi/chi/v5" +) + +func TestWebhookHandler_Sonarr(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(svc *mock.FakeSonarrService) + body io.Reader + expectedStatus int + target any + expectedResponse any + }{ + { + name: "OK: event type=Download", + setup: func(svc *mock.FakeSonarrService) { + svc.ProcessReturns(nil) + }, + body: func() *bytes.Buffer { + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(rest.SonarrWebhookRequest{ + EventType: "Download", + Series: rest.SonarrSeries{ + ID: 1, + Title: "Series 1", + }, + Episodes: []rest.SonarrEpisode{ + { + ID: 1, + EpisodeNumber: 1, + SeasonNumber: 1, + Title: "Ep 1", + }, + }, + }) + return &buf + }(), + expectedStatus: http.StatusNoContent, + }, + { + name: "ERR: only JSON is accepted", + setup: func(svc *mock.FakeSonarrService) {}, + body: func() *bytes.Buffer { + var buf bytes.Buffer + _ = gob.NewEncoder(&buf).Encode(rest.SonarrWebhookRequest{ + EventType: "Download", + Series: rest.SonarrSeries{ + ID: 1, + Title: "Series 1", + }, + Episodes: []rest.SonarrEpisode{ + { + ID: 1, + EpisodeNumber: 1, + SeasonNumber: 1, + Title: "Ep 1", + }, + }, + }) + return &buf + }(), + expectedStatus: http.StatusBadRequest, + target: &rest.ErrorResponse{}, + expectedResponse: &rest.ErrorResponse{ + Error: rest.APIError{ + Code: domain.ErrorCodeValidation.String(), + Message: "invalid request body", + }, + }, + }, + { + name: "ERR: unsupported event type", + setup: func(svc *mock.FakeSonarrService) {}, + body: func() *bytes.Buffer { + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(rest.SonarrWebhookRequest{ + EventType: "xxxx", + }) + return &buf + }(), + expectedStatus: http.StatusBadRequest, + target: &rest.ErrorResponse{}, + expectedResponse: &rest.ErrorResponse{ + Error: rest.APIError{ + Code: domain.ErrUnsupportedSonarrEventType.Code().String(), + Message: domain.ErrUnsupportedSonarrEventType.Message(), + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + svc := mock.FakeSonarrService{} + if tt.setup != nil { + tt.setup(&svc) + } + router := chi.NewRouter() + rest.NewWebhookHandler(&svc).Register(router) + + resp := doRequest(router, http.MethodPost, "/webhook/sonarr", tt.body) + defer assert.NoError(t, resp.Body.Close()) + if tt.target != nil && tt.expectedResponse != nil { + assertJSONResponse(t, resp, tt.expectedStatus, tt.expectedResponse, tt.target) + } else { + assert.Equal(t, tt.expectedStatus, resp.StatusCode) + } + }) + } +} diff --git a/internal/service/internal/mock/publisher.gen.go b/internal/service/internal/mock/publisher.gen.go new file mode 100644 index 0000000..2962983 --- /dev/null +++ b/internal/service/internal/mock/publisher.gen.go @@ -0,0 +1,116 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package mock + +import ( + "context" + "sync" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service" +) + +type FakePublisher struct { + PublishStub func(context.Context, string, string) error + publishMutex sync.RWMutex + publishArgsForCall []struct { + arg1 context.Context + arg2 string + arg3 string + } + publishReturns struct { + result1 error + } + publishReturnsOnCall map[int]struct { + result1 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakePublisher) Publish(arg1 context.Context, arg2 string, arg3 string) error { + fake.publishMutex.Lock() + ret, specificReturn := fake.publishReturnsOnCall[len(fake.publishArgsForCall)] + fake.publishArgsForCall = append(fake.publishArgsForCall, struct { + arg1 context.Context + arg2 string + arg3 string + }{arg1, arg2, arg3}) + stub := fake.PublishStub + fakeReturns := fake.publishReturns + fake.recordInvocation("Publish", []interface{}{arg1, arg2, arg3}) + fake.publishMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakePublisher) PublishCallCount() int { + fake.publishMutex.RLock() + defer fake.publishMutex.RUnlock() + return len(fake.publishArgsForCall) +} + +func (fake *FakePublisher) PublishCalls(stub func(context.Context, string, string) error) { + fake.publishMutex.Lock() + defer fake.publishMutex.Unlock() + fake.PublishStub = stub +} + +func (fake *FakePublisher) PublishArgsForCall(i int) (context.Context, string, string) { + fake.publishMutex.RLock() + defer fake.publishMutex.RUnlock() + argsForCall := fake.publishArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakePublisher) PublishReturns(result1 error) { + fake.publishMutex.Lock() + defer fake.publishMutex.Unlock() + fake.PublishStub = nil + fake.publishReturns = struct { + result1 error + }{result1} +} + +func (fake *FakePublisher) PublishReturnsOnCall(i int, result1 error) { + fake.publishMutex.Lock() + defer fake.publishMutex.Unlock() + fake.PublishStub = nil + if fake.publishReturnsOnCall == nil { + fake.publishReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.publishReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakePublisher) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.publishMutex.RLock() + defer fake.publishMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakePublisher) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ service.Publisher = new(FakePublisher) diff --git a/internal/service/ntfy.go b/internal/service/ntfy.go new file mode 100644 index 0000000..42e3a56 --- /dev/null +++ b/internal/service/ntfy.go @@ -0,0 +1,61 @@ +package service + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" +) + +type Ntfy struct { + client *http.Client + url string + topic string + username string + password string +} + +func NewNtfy(client *http.Client, url string, topic string, username string, password string) *Ntfy { + return &Ntfy{ + client: client, + url: url, + topic: topic, + username: username, + password: password, + } +} + +func (n *Ntfy) Publish(ctx context.Context, title, message string) error { + // initialize request + req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.url+"/"+n.topic, strings.NewReader(message)) + if err != nil { + return fmt.Errorf("http.NewRequestWithContext: %w", err) + } + + // set required headers + req.Header.Set("Title", title) + if n.username != "" && n.password != "" { + req.SetBasicAuth(n.username, n.password) + } + + // send request + resp, err := n.client.Do(req) + if err != nil { + return fmt.Errorf("client.Do: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + _, _ = io.Copy(io.Discard, resp.Body) // discard body, as it is not needed + if resp.StatusCode != http.StatusOK { + return domain.NewError( + domain.WithCode(domain.ErrorCodeIO), + domain.WithMessagef("ntfy returned unexpected status code: %d", resp.StatusCode), + ) + } + + return nil +} diff --git a/internal/service/ntfy_test.go b/internal/service/ntfy_test.go new file mode 100644 index 0000000..a265b3d --- /dev/null +++ b/internal/service/ntfy_test.go @@ -0,0 +1,98 @@ +package service_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service" + "github.com/stretchr/testify/assert" +) + +func TestNtfy_Publish(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + tests := []struct { + name string + topic string + title string + message string + username string + password string + }{ + { + name: "without authorization", + topic: "topicc", + title: "title", + message: "msg", + username: "", + password: "", + }, + { + name: "with authorization", + topic: "topicc", + title: "title", + message: "msg", + username: "uname", + password: "pwd", + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path[1:] != tt.topic { + w.WriteHeader(http.StatusNotFound) + return + } + + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if r.Header.Get("Title") != tt.title { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("invalid title")) + return + } + + if uname, pwd, _ := r.BasicAuth(); uname != tt.username || pwd != tt.password { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("invalid username or password")) + return + } + + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + err := service.NewNtfy(srv.Client(), srv.URL, tt.topic, tt.username, tt.password). + Publish(context.Background(), tt.title, tt.message) + assert.NoError(t, err) + }) + } + }) + + t.Run("ERR: status code != 200", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) + })) + defer srv.Close() + + err := service.NewNtfy(srv.Client(), srv.URL, "topic", "", ""). + Publish(context.Background(), "title", "msg") + assert.ErrorIs(t, err, domain.NewError( + domain.WithCode(domain.ErrorCodeIO), + domain.WithMessagef("ntfy returned unexpected status code: %d", http.StatusNotImplemented), + )) + }) +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..e01775a --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,10 @@ +package service + +import "context" + +//go:generate counterfeiter -generate + +//counterfeiter:generate -o internal/mock/publisher.gen.go . Publisher +type Publisher interface { + Publish(ctx context.Context, title, message string) error +} diff --git a/internal/service/sonaar.go b/internal/service/sonaar.go new file mode 100644 index 0000000..9228c00 --- /dev/null +++ b/internal/service/sonaar.go @@ -0,0 +1,58 @@ +package service + +import ( + "context" + "fmt" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" +) + +type Sonarr struct { + publisher Publisher +} + +func NewSonaar(publisher Publisher) *Sonarr { + return &Sonarr{ + publisher: publisher, + } +} + +func (s *Sonarr) Process(ctx context.Context, payload domain.SonarrWebhookPayload) error { + for _, ep := range payload.Episodes { + title, err := s.buildTitle(payload.EventType, payload.Series, ep) + if err != nil { + return fmt.Errorf("buildTitle: %w", err) + } + + msg, err := s.buildMessage(payload.EventType, payload.Series, ep) + if err != nil { + return fmt.Errorf("buildMessage: %w", err) + } + + if err := s.publisher.Publish(ctx, title, msg); err != nil { + return fmt.Errorf("Publisher.Publish: %w", err) + } + } + + return nil +} + +func (s *Sonarr) buildTitle(evType domain.SonarrEventType, series domain.SonarrSeries, _ domain.SonarrEpisode) (string, error) { + switch evType { + case domain.SonarrEventTypeDownload, + domain.SonarrEventTypeTest: + return series.Title + " - New episode (Sonarr)", nil + default: + return "", domain.ErrUnsupportedSonarrEventType + } +} + +func (s *Sonarr) buildMessage(evType domain.SonarrEventType, _ domain.SonarrSeries, ep domain.SonarrEpisode) (string, error) { + switch evType { + case domain.SonarrEventTypeDownload, + domain.SonarrEventTypeTest: + return fmt.Sprintf("S%d.E%d %s", ep.SeasonNumber, ep.EpisodeNumber, ep.Title), nil + default: + return "", domain.ErrUnsupportedSonarrEventType + } +} diff --git a/internal/service/sonarr_test.go b/internal/service/sonarr_test.go new file mode 100644 index 0000000..a1053a7 --- /dev/null +++ b/internal/service/sonarr_test.go @@ -0,0 +1,75 @@ +package service_test + +import ( + "context" + "fmt" + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/stretchr/testify/assert" + + "github.com/google/uuid" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service" + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service/internal/mock" +) + +func TestSonarr_Process(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + for _, evType := range [...]domain.SonarrEventType{ + domain.SonarrEventTypeDownload, + domain.SonarrEventTypeTest, + } { + t.Run("event type="+evType.String(), func(t *testing.T) { + t.Parallel() + + publisher := &mock.FakePublisher{} + publisher.PublishReturns(nil) + + payload := generateSonarrWebhookPayload(evType) + + err := service.NewSonaar(publisher).Process(context.Background(), payload) + assert.NoError(t, err) + require.Equal(t, len(payload.Episodes), publisher.PublishCallCount()) + for i, ep := range payload.Episodes { + _, title, msg := publisher.PublishArgsForCall(i) + assert.Equal(t, payload.Series.Title+" - New episode (Sonarr)", title) + assert.Equal(t, fmt.Sprintf("S%d.E%d %s", ep.SeasonNumber, ep.EpisodeNumber, ep.Title), msg) + } + }) + } + }) +} + +func generateSonarrWebhookPayload(ev domain.SonarrEventType) domain.SonarrWebhookPayload { + s := rand.NewSource(time.Now().UnixNano()) + r := rand.New(s) + + numEps := r.Int31n(19) + 1 + eps := make([]domain.SonarrEpisode, 0, numEps) + for i := int32(0); i < numEps; i++ { + eps = append(eps, domain.SonarrEpisode{ + ID: r.Int63(), + EpisodeNumber: int16(r.Int31n(100)), + SeasonNumber: int16(r.Int31n(100)), + Title: uuid.NewString(), + }) + } + + return domain.SonarrWebhookPayload{ + EventType: ev, + Series: domain.SonarrSeries{ + ID: r.Int63(), + Title: uuid.NewString(), + }, + Episodes: eps, + } +} diff --git a/internal/tools/go.mod b/internal/tools/go.mod index d796257..98b2f60 100644 --- a/internal/tools/go.mod +++ b/internal/tools/go.mod @@ -2,7 +2,10 @@ module gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/tools go 1.18 -require github.com/golangci/golangci-lint v1.46.2 +require ( + github.com/golangci/golangci-lint v1.46.2 + github.com/maxbrunsfeld/counterfeiter/v6 v6.5.0 +) require ( 4d63.com/gochecknoglobals v0.1.0 // indirect diff --git a/internal/tools/go.sum b/internal/tools/go.sum index a01400e..09d2148 100644 --- a/internal/tools/go.sum +++ b/internal/tools/go.sum @@ -533,6 +533,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/maxbrunsfeld/counterfeiter/v6 v6.5.0 h1:rBhB9Rls+yb8kA4x5a/cWxOufWfXt24E+kq4YlbGj3g= +github.com/maxbrunsfeld/counterfeiter/v6 v6.5.0/go.mod h1:fJ0UAZc1fx3xZhU4eSHQDJ1ApFmTVhp5VTpV9tm2ogg= github.com/mbilski/exhaustivestruct v1.2.0 h1:wCBmUnSYufAHO6J4AVWY6ff+oxWxsVFrwgOdMUQePUo= github.com/mbilski/exhaustivestruct v1.2.0/go.mod h1:OeTBVxQWoEmB2J2JCHmXWPJ0aksxSUOUy+nvtVEfzXc= github.com/mgechev/dots v0.0.0-20210922191527-e955255bf517/go.mod h1:KQ7+USdGKfpPjXk4Ga+5XxQM4Lm4e3gAogrreFAYpOg= @@ -573,7 +575,6 @@ github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81 github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 h1:4kuARK6Y6FxaNu/BnU2OAaLF86eTVhP2hjTB6iMvItA= github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354/go.mod h1:KSVJerMDfblTH7p5MZaTt+8zaT2iEk3AkVb9PQdZuE8= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nishanths/exhaustive v0.7.11 h1:xV/WU3Vdwh5BUH4N06JNUznb6d5zhRPOnlgCrpNYNKA= github.com/nishanths/exhaustive v0.7.11/go.mod h1:gX+MP7DWMKJmNa1HfMozK+u04hQd3na9i0hyqf3/dOI= @@ -689,6 +690,7 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb github.com/sagikazarmark/crypt v0.5.0/go.mod h1:l+nzl7KWh51rpzp2h7t4MZWyiEWdhNpOAnclKvg+mdA= github.com/sanposhiho/wastedassign/v2 v2.0.6 h1:+6/hQIHKNJAUixEj6EmOngGIisyeI+T3335lYTyxRoA= github.com/sanposhiho/wastedassign/v2 v2.0.6/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI= +github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/securego/gosec/v2 v2.11.0 h1:+PDkpzR41OI2jrw1q6AdXZCbsNGNGT7pQjal0H0cArI= github.com/securego/gosec/v2 v2.11.0/go.mod h1:SX8bptShuG8reGC0XS09+a4H2BoWSJi+fscA+Pulbpo= @@ -1365,8 +1367,8 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/cheggaaa/pb.v1 v1.0.28/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= diff --git a/internal/tools/tools.go b/internal/tools/tools.go index f6d0a8c..17bfabd 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -5,4 +5,5 @@ package tools import ( _ "github.com/golangci/golangci-lint/cmd/golangci-lint" + _ "github.com/maxbrunsfeld/counterfeiter/v6" ) diff --git a/main.go b/main.go index 142bdf6..0d7759f 100644 --- a/main.go +++ b/main.go @@ -2,32 +2,40 @@ package main import ( "context" - "io" + "errors" + "fmt" "log" "net/http" "os" "os/signal" "syscall" "time" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/rest" + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" ) const ( - defaultPort = "9234" - readTimeout = 2 * time.Second - readHeaderTimeout = 2 * time.Second - writeTimeout = 2 * time.Second - idleTimeout = 2 * time.Second - serverShutdownTimeout = 10 * time.Second + defaultPort = "9234" + readTimeout = 2 * time.Second + readHeaderTimeout = 2 * time.Second + writeTimeout = 2 * time.Second + idleTimeout = 2 * time.Second + serverShutdownTimeout = 10 * time.Second + ntfyDefaultUrl = "https://ntfy.sh" + publisherDefaultTimeout = 5 * time.Second ) func main() { - srv := newServer() + srv, err := newServer() + if err != nil { + log.Fatalln("newServer:", err) + } - go func(srv *http.Server) { - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalln("srv.ListenAndServe:", err) - } - }(srv) + go startServer(srv) log.Println("Server is listening on the port", defaultPort) @@ -35,23 +43,83 @@ func main() { ctxShutdown, cancelShutdown := context.WithTimeout(context.Background(), serverShutdownTimeout) defer cancelShutdown() - err := srv.Shutdown(ctxShutdown) + err = srv.Shutdown(ctxShutdown) if err != nil { log.Println("srv.Shutdown:", err) } } -func newServer() *http.Server { +func newServer() (*http.Server, error) { + router, err := newRouter() + if err != nil { + return nil, fmt.Errorf("newRouter: %w", err) + } + return &http.Server{ - Addr: ":" + defaultPort, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = io.Copy(os.Stdout, r.Body) - w.WriteHeader(http.StatusOK) - }), + Addr: ":" + defaultPort, + Handler: router, ReadTimeout: readTimeout, ReadHeaderTimeout: readHeaderTimeout, WriteTimeout: writeTimeout, IdleTimeout: idleTimeout, + }, nil +} + +func newRouter() (*chi.Mux, error) { + sonarrSvc, err := newSonarrService() + if err != nil { + return nil, fmt.Errorf("newSonarrService: %w", err) + } + + r := chi.NewRouter() + r.Use( + middleware.RealIP, + middleware.RequestLogger(&middleware.DefaultLogFormatter{ + NoColor: true, + Logger: log.Default(), + }), + middleware.Recoverer, + middleware.Heartbeat("/health"), + ) + rest.NewWebhookHandler(sonarrSvc).Register(r) + return r, nil +} + +func newSonarrService() (*service.Sonarr, error) { + url := os.Getenv("SONARR_NTFY_URL") + if url == "" { + url = ntfyDefaultUrl + } + + topic := os.Getenv("SONARR_NTFY_TOPIC") + if topic == "" { + return nil, errors.New(`env "SONARR_NTFY_TOPIC" is required`) + } + + timeout := os.Getenv("SONARR_NTFY_REQ_TIMEOUT") + parsedTimeout := publisherDefaultTimeout + var err error + if timeout != "" { + parsedTimeout, err = time.ParseDuration(timeout) + } + if err != nil { + return nil, fmt.Errorf(`os.Getenv("SONARR_NTFY_REQ_TIMEOUT"): %w`, err) + } + + return service.NewSonaar( + service.NewNtfy( + &http.Client{Timeout: parsedTimeout}, + url, + topic, + os.Getenv("SONARR_NTFY_USERNAME"), + os.Getenv("SONARR_NTFY_PASSWORD"), + ), + ), nil +} + +func startServer(srv *http.Server) { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalln("srv.ListenAndServe:", err) } }