diff --git a/internal/domain/errors_test.go b/internal/domain/errors_test.go index 3955fab..f346ca7 100644 --- a/internal/domain/errors_test.go +++ b/internal/domain/errors_test.go @@ -18,6 +18,8 @@ func TestErrorCode_String(t *testing.T) { } func TestError(t *testing.T) { + t.Parallel() + errToWrap := errors.New("WithErr") tests := []struct { name string diff --git a/internal/domain/radarr_webhook_payload.go b/internal/domain/radarr_webhook_payload.go new file mode 100644 index 0000000..676ab90 --- /dev/null +++ b/internal/domain/radarr_webhook_payload.go @@ -0,0 +1,38 @@ +package domain + +type RadarrEventType string + +const ( + RadarrEventTypeDownload RadarrEventType = "Download" + RadarrEventTypeTest RadarrEventType = "Test" +) + +var ( + ErrUnsupportedRadarrEventType = NewError(WithCode(ErrorCodeValidation), WithMessage("unsupported event type")) +) + +func NewRadarrEventType(s string) (RadarrEventType, error) { + conv := RadarrEventType(s) + switch conv { + case RadarrEventTypeDownload, + RadarrEventTypeTest: + return conv, nil + default: + return "", ErrUnsupportedSonarrEventType + } +} + +func (r RadarrEventType) String() string { + return string(r) +} + +type RadarrMovie struct { + ID int64 + Title string + ReleaseDate string +} + +type RadarrWebhookPayload struct { + EventType RadarrEventType + Movie RadarrMovie +} diff --git a/internal/domain/radarr_webhook_payload_test.go b/internal/domain/radarr_webhook_payload_test.go new file mode 100644 index 0000000..2518241 --- /dev/null +++ b/internal/domain/radarr_webhook_payload_test.go @@ -0,0 +1,36 @@ +package domain_test + +import ( + "testing" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" + "github.com/stretchr/testify/assert" +) + +func TestNewRadarrEventType(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + events := []domain.RadarrEventType{domain.RadarrEventTypeDownload, domain.RadarrEventTypeTest} + + for _, ev := range events { + res, err := domain.NewRadarrEventType(ev.String()) + assert.Equal(t, ev, res) + assert.NoError(t, err) + } + }) + + t.Run("ERR: invalid event type", func(t *testing.T) { + t.Parallel() + + events := []string{"test1", "test2", "aaaa", "bbb"} + + for _, ev := range events { + res, err := domain.NewRadarrEventType(ev) + assert.Zero(t, res) + assert.ErrorIs(t, err, domain.ErrUnsupportedRadarrEventType) + } + }) +} diff --git a/internal/domain/sonarr_webhook_payload_test.go b/internal/domain/sonarr_webhook_payload_test.go index 5027bfd..78d4494 100644 --- a/internal/domain/sonarr_webhook_payload_test.go +++ b/internal/domain/sonarr_webhook_payload_test.go @@ -12,7 +12,9 @@ func TestNewSonarrEventType(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { - events := []domain.SonarrEventType{domain.SonarrEventTypeDownload} + t.Parallel() + + events := []domain.SonarrEventType{domain.SonarrEventTypeDownload, domain.SonarrEventTypeTest} for _, ev := range events { res, err := domain.NewSonarrEventType(ev.String()) @@ -22,6 +24,8 @@ func TestNewSonarrEventType(t *testing.T) { }) t.Run("ERR: invalid event type", func(t *testing.T) { + t.Parallel() + events := []string{"test1", "test2", "aaaa", "bbb"} for _, ev := range events { diff --git a/internal/rest/internal/mock/radarr_service.gen.go b/internal/rest/internal/mock/radarr_service.gen.go new file mode 100644 index 0000000..06729ec --- /dev/null +++ b/internal/rest/internal/mock/radarr_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 FakeRadarrService struct { + ProcessStub func(context.Context, domain.RadarrWebhookPayload) error + processMutex sync.RWMutex + processArgsForCall []struct { + arg1 context.Context + arg2 domain.RadarrWebhookPayload + } + processReturns struct { + result1 error + } + processReturnsOnCall map[int]struct { + result1 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeRadarrService) Process(arg1 context.Context, arg2 domain.RadarrWebhookPayload) error { + fake.processMutex.Lock() + ret, specificReturn := fake.processReturnsOnCall[len(fake.processArgsForCall)] + fake.processArgsForCall = append(fake.processArgsForCall, struct { + arg1 context.Context + arg2 domain.RadarrWebhookPayload + }{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 *FakeRadarrService) ProcessCallCount() int { + fake.processMutex.RLock() + defer fake.processMutex.RUnlock() + return len(fake.processArgsForCall) +} + +func (fake *FakeRadarrService) ProcessCalls(stub func(context.Context, domain.RadarrWebhookPayload) error) { + fake.processMutex.Lock() + defer fake.processMutex.Unlock() + fake.ProcessStub = stub +} + +func (fake *FakeRadarrService) ProcessArgsForCall(i int) (context.Context, domain.RadarrWebhookPayload) { + fake.processMutex.RLock() + defer fake.processMutex.RUnlock() + argsForCall := fake.processArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeRadarrService) ProcessReturns(result1 error) { + fake.processMutex.Lock() + defer fake.processMutex.Unlock() + fake.ProcessStub = nil + fake.processReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeRadarrService) 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 *FakeRadarrService) 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 *FakeRadarrService) 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.RadarrService = new(FakeRadarrService) diff --git a/internal/rest/webhook_handler.go b/internal/rest/webhook_handler.go index d11db43..fc790dc 100644 --- a/internal/rest/webhook_handler.go +++ b/internal/rest/webhook_handler.go @@ -16,18 +16,26 @@ type SonarrService interface { Process(ctx context.Context, payload domain.SonarrWebhookPayload) error } -type WebhookHandler struct { - sonarrSvc SonarrService +//counterfeiter:generate -o internal/mock/radarr_service.gen.go . RadarrService +type RadarrService interface { + Process(ctx context.Context, payload domain.RadarrWebhookPayload) error } -func NewWebhookHandler(sonarrSvc SonarrService) *WebhookHandler { +type WebhookHandler struct { + sonarrSvc SonarrService + radarrSvc RadarrService +} + +func NewWebhookHandler(sonarrSvc SonarrService, radarrSvc RadarrService) *WebhookHandler { return &WebhookHandler{ sonarrSvc: sonarrSvc, + radarrSvc: radarrSvc, } } func (h *WebhookHandler) Register(r chi.Router) { r.Post("/webhook/sonarr", h.handleSonarrWebhook) + r.Post("/webhook/radarr", h.handleRadarrWebhook) } type SonarrSeries struct { @@ -90,3 +98,48 @@ func (h *WebhookHandler) handleSonarrWebhook(w http.ResponseWriter, r *http.Requ w.WriteHeader(http.StatusNoContent) } + +type RadarrMovie struct { + ID int64 `json:"id"` + Title string `json:"title"` + ReleaseDate string `json:"releaseDate"` +} + +type RadarrWebhookRequest struct { + EventType string `json:"eventType"` + Movie RadarrMovie `json:"movie"` +} + +func (h *WebhookHandler) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) { + var req RadarrWebhookRequest + 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.NewRadarrEventType(req.EventType) + if err != nil { + renderErrorResponse(w, fmt.Errorf("domain.NewRadarrEventType: %w", err)) + return + } + + payload := domain.RadarrWebhookPayload{ + EventType: eventType, + Movie: domain.RadarrMovie{ + ID: req.Movie.ID, + Title: req.Movie.Title, + ReleaseDate: req.Movie.ReleaseDate, + }, + } + + if err := h.radarrSvc.Process(r.Context(), payload); err != nil { + renderErrorResponse(w, fmt.Errorf("RadarrService.Process: %w", err)) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/rest/webhook_handler_test.go b/internal/rest/webhook_handler_test.go index bcc641b..b47e674 100644 --- a/internal/rest/webhook_handler_test.go +++ b/internal/rest/webhook_handler_test.go @@ -117,7 +117,7 @@ func TestWebhookHandler_Sonarr(t *testing.T) { tt.setup(&svc) } router := chi.NewRouter() - rest.NewWebhookHandler(&svc).Register(router) + rest.NewWebhookHandler(&svc, nil).Register(router) resp := doRequest(router, http.MethodPost, "/webhook/sonarr", tt.body) defer assert.NoError(t, resp.Body.Close()) @@ -129,3 +129,102 @@ func TestWebhookHandler_Sonarr(t *testing.T) { }) } } + +func TestWebhookHandler_Radarr(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(svc *mock.FakeRadarrService) + body io.Reader + expectedStatus int + target any + expectedResponse any + }{ + { + name: "OK: event type=Download", + setup: func(svc *mock.FakeRadarrService) { + svc.ProcessReturns(nil) + }, + body: func() *bytes.Buffer { + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(rest.RadarrWebhookRequest{ + EventType: "Download", + Movie: rest.RadarrMovie{ + ID: 111, + Title: "Title 1", + ReleaseDate: "1970-01-01", + }, + }) + return &buf + }(), + expectedStatus: http.StatusNoContent, + }, + { + name: "ERR: only JSON is accepted", + setup: func(svc *mock.FakeRadarrService) {}, + body: func() *bytes.Buffer { + var buf bytes.Buffer + _ = gob.NewEncoder(&buf).Encode(rest.RadarrWebhookRequest{ + EventType: "Download", + Movie: rest.RadarrMovie{ + ID: 111, + Title: "Title 1", + ReleaseDate: "1970-01-01", + }, + }) + 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.FakeRadarrService) {}, + body: func() *bytes.Buffer { + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(rest.RadarrWebhookRequest{ + EventType: "xxxx", + }) + return &buf + }(), + expectedStatus: http.StatusBadRequest, + target: &rest.ErrorResponse{}, + expectedResponse: &rest.ErrorResponse{ + Error: rest.APIError{ + Code: domain.ErrUnsupportedRadarrEventType.Code().String(), + Message: domain.ErrUnsupportedRadarrEventType.Message(), + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + svc := mock.FakeRadarrService{} + if tt.setup != nil { + tt.setup(&svc) + } + router := chi.NewRouter() + rest.NewWebhookHandler(nil, &svc).Register(router) + + resp := doRequest(router, http.MethodPost, "/webhook/radarr", 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/radarr.go b/internal/service/radarr.go new file mode 100644 index 0000000..fc689f3 --- /dev/null +++ b/internal/service/radarr.go @@ -0,0 +1,56 @@ +package service + +import ( + "context" + "fmt" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" +) + +type Radarr struct { + publisher Publisher +} + +func NewRadarr(publisher Publisher) *Radarr { + return &Radarr{ + publisher: publisher, + } +} + +func (s *Radarr) Process(ctx context.Context, payload domain.RadarrWebhookPayload) error { + title, err := s.buildTitle(payload.EventType, payload.Movie) + if err != nil { + return fmt.Errorf("buildTitle: %w", err) + } + + msg, err := s.buildMessage(payload.EventType, payload.Movie) + 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 *Radarr) buildTitle(evType domain.RadarrEventType, _ domain.RadarrMovie) (string, error) { + switch evType { + case domain.RadarrEventTypeDownload, + domain.RadarrEventTypeTest: + return "New movie available (Radarr)", nil + default: + return "", domain.ErrUnsupportedRadarrEventType + } +} + +func (s *Radarr) buildMessage(evType domain.RadarrEventType, movie domain.RadarrMovie) (string, error) { + switch evType { + case domain.RadarrEventTypeDownload, + domain.RadarrEventTypeTest: + return movie.Title, nil + default: + return "", domain.ErrUnsupportedRadarrEventType + } +} diff --git a/internal/service/radarr_test.go b/internal/service/radarr_test.go new file mode 100644 index 0000000..c3021b9 --- /dev/null +++ b/internal/service/radarr_test.go @@ -0,0 +1,56 @@ +package service_test + +import ( + "context" + "math/rand" + "testing" + "time" + + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain" + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service" + "gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service/internal/mock" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestRadarr_Process(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + for _, evType := range [...]domain.RadarrEventType{ + domain.RadarrEventTypeDownload, + domain.RadarrEventTypeTest, + } { + t.Run("event type="+evType.String(), func(t *testing.T) { + t.Parallel() + + publisher := &mock.FakePublisher{} + publisher.PublishReturns(nil) + + payload := generateRadarrWebhookPayload(evType) + + err := service.NewRadarr(publisher).Process(context.Background(), payload) + assert.NoError(t, err) + _, title, msg := publisher.PublishArgsForCall(0) + assert.Equal(t, "New movie available (Radarr)", title) + assert.Equal(t, payload.Movie.Title, msg) + }) + } + }) +} + +func generateRadarrWebhookPayload(ev domain.RadarrEventType) domain.RadarrWebhookPayload { + s := rand.NewSource(time.Now().UnixNano()) + r := rand.New(s) + + return domain.RadarrWebhookPayload{ + EventType: ev, + Movie: domain.RadarrMovie{ + ID: r.Int63(), + Title: uuid.NewString(), + ReleaseDate: "1970-01-01", + }, + } +} diff --git a/internal/service/sonarr.go b/internal/service/sonarr.go index 582dfbe..52fe2ef 100644 --- a/internal/service/sonarr.go +++ b/internal/service/sonarr.go @@ -41,7 +41,7 @@ func (s *Sonarr) buildTitle(evType domain.SonarrEventType, series domain.SonarrS switch evType { case domain.SonarrEventTypeDownload, domain.SonarrEventTypeTest: - return series.Title + " - New episode (Sonarr)", nil + return series.Title + " - New episode available (Sonarr)", nil default: return "", domain.ErrUnsupportedSonarrEventType } diff --git a/internal/service/sonarr_test.go b/internal/service/sonarr_test.go index cc912c4..d145295 100644 --- a/internal/service/sonarr_test.go +++ b/internal/service/sonarr_test.go @@ -41,7 +41,7 @@ func TestSonarr_Process(t *testing.T) { 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, payload.Series.Title+" - New episode available (Sonarr)", title) assert.Equal(t, fmt.Sprintf("S%d.E%d %s", ep.SeasonNumber, ep.EpisodeNumber, ep.Title), msg) } }) diff --git a/main.go b/main.go index b5cbc54..deed2d1 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "fmt" "log" "net/http" @@ -71,6 +70,11 @@ func newRouter() (*chi.Mux, error) { return nil, fmt.Errorf("newSonarrService: %w", err) } + radarrSvc, err := newRadarrService() + if err != nil { + return nil, fmt.Errorf("newRadarrService: %w", err) + } + r := chi.NewRouter() r.Use( middleware.RealIP, @@ -81,39 +85,53 @@ func newRouter() (*chi.Mux, error) { middleware.Recoverer, middleware.Heartbeat("/health"), ) - rest.NewWebhookHandler(sonarrSvc).Register(r) + rest.NewWebhookHandler(sonarrSvc, radarrSvc).Register(r) return r, nil } +func newRadarrService() (*service.Radarr, error) { + ntfy, err := newNtfyService("RADARR") + if err != nil { + return nil, fmt.Errorf("newNtfyService: %w", err) + } + return service.NewRadarr(ntfy), nil +} + func newSonarrService() (*service.Sonarr, error) { - url := os.Getenv("SONARR_NTFY_URL") + ntfy, err := newNtfyService("SONARR") + if err != nil { + return nil, fmt.Errorf("newNtfyService: %w", err) + } + return service.NewSonarr(ntfy), nil +} + +func newNtfyService(envPrefix string) (*service.Ntfy, error) { + url := os.Getenv(envPrefix + "_NTFY_URL") if url == "" { url = ntfyDefaultUrl } - topic := os.Getenv("SONARR_NTFY_TOPIC") + topic := os.Getenv(envPrefix + "_NTFY_TOPIC") if topic == "" { - return nil, errors.New(`env "SONARR_NTFY_TOPIC" is required`) + return nil, fmt.Errorf(`env "%s_NTFY_TOPIC" is required`, envPrefix) } - timeout := os.Getenv("SONARR_NTFY_REQ_TIMEOUT") + timeout := os.Getenv(envPrefix + "_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 nil, fmt.Errorf(`os.Getenv("%s_NTFY_REQ_TIMEOUT"): %w`, envPrefix, err) } - return service.NewSonarr( - service.NewNtfy( - &http.Client{Timeout: parsedTimeout}, - url, - topic, - os.Getenv("SONARR_NTFY_USERNAME"), - os.Getenv("SONARR_NTFY_PASSWORD"), - ), + return service.NewNtfy( + &http.Client{Timeout: parsedTimeout}, + url, + topic, + os.Getenv(envPrefix+"_NTFY_USERNAME"), + os.Getenv(envPrefix+"_NTFY_PASSWORD"), ), nil }