diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c184b18..7950fea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,6 +44,11 @@ jobs: run: | make vendor + # test + - name: tests + run: | + make test + # git status - run: git status diff --git a/Makefile b/Makefile index 45f4757..40eaea6 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,10 @@ CGO := 0 check_goreleaser: @command -v goreleaser >/dev/null || (echo "goreleaser is required."; exit 1) +.PHONY: test +test: ## Run tests + go test ./... -cover -v -race ${GO_PACKAGES} + .PHONY: vendor vendor: ## Vendor files and tidy go.mod go mod vendor diff --git a/cache/cache.go b/cache/cache.go index dc8cf5e..1e4bb9b 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -1,19 +1,15 @@ package cache import ( - "context" "fmt" "github.com/l3uddz/nabarr/logger" - "github.com/lefelys/state" "github.com/rs/zerolog" "github.com/xujiajun/nutsdb" - "time" ) type Client struct { log zerolog.Logger - st state.State db *nutsdb.DB } @@ -33,54 +29,14 @@ func New(path string) (*Client, error) { log := logger.New("trace").With().Logger() - // start cleaner - st, tail := state.WithShutdown() - ticker := time.NewTicker(24 * time.Hour) - go func() { - for { - select { - case <-tail.End(): - ticker.Stop() - tail.Done() - return - case <-ticker.C: - // clean cache - err := db.Update(func(tx *nutsdb.Tx) error { - return db.Merge() - }) - - switch { - case err == nil: - log.Info().Msg("Cleaned cache") - case err.Error() == "the number of files waiting to be merged is at least 2": - // there were no data files to be merged - default: - // unexpected error - log.Error(). - Err(err). - Msg("Failed cleaning cache") - } - } - } - }() - return &Client{ log: log, - st: st, - db: db, + + db: db, }, nil } func (c *Client) Close() error { - // shutdown cleaner - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - if err := c.st.Shutdown(ctx); err != nil { - c.log.Error(). - Err(err). - Msg("Failed shutting down cache cleaner gracefully") - } - // close cache return c.db.Close() } diff --git a/cache/delete_test.go b/cache/delete_test.go new file mode 100644 index 0000000..52cfa91 --- /dev/null +++ b/cache/delete_test.go @@ -0,0 +1,45 @@ +package cache + +import ( + "github.com/rs/zerolog" + "testing" +) + +func TestClient_Delete(t *testing.T) { + type fields struct { + log zerolog.Logger + } + type args struct { + bucket string + key string + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "key not present", + fields: fields{ + log: zerolog.Logger{}, + }, + args: args{ + bucket: "delete", + key: "test", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ + log: tt.fields.log, + db: newDb(t, "delete"), + } + if err := c.Delete(tt.args.bucket, tt.args.key); (err != nil) != tt.wantErr { + t.Errorf("Delete() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/cache/get_test.go b/cache/get_test.go new file mode 100644 index 0000000..481904d --- /dev/null +++ b/cache/get_test.go @@ -0,0 +1,98 @@ +package cache + +import ( + "github.com/rs/zerolog" + "reflect" + "testing" + "time" +) + +func TestClient_Get(t *testing.T) { + type fields struct { + log zerolog.Logger + } + type args struct { + bucket string + key string + } + tests := []struct { + name string + fields fields + args args + sleep time.Duration + put bool + ttl time.Duration + want []byte + wantErr bool + }{ + { + name: "no value", + fields: fields{ + log: zerolog.Logger{}, + }, + args: args{ + bucket: "get", + key: "test", + }, + want: nil, + wantErr: true, + }, + { + name: "with value", + fields: fields{ + log: zerolog.Logger{}, + }, + args: args{ + bucket: "get", + key: "test", + }, + sleep: 1 * time.Second, + put: true, + ttl: 2 * time.Second, + want: []byte("test"), + wantErr: false, + }, + { + name: "no value post ttl", + fields: fields{ + log: zerolog.Logger{}, + }, + args: args{ + bucket: "get", + key: "test", + }, + sleep: 1 * time.Second, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ + log: tt.fields.log, + db: newDb(t, "get"), + } + + if tt.put { + if err := c.Put(tt.args.bucket, tt.args.key, tt.want, tt.ttl); (err != nil) != tt.wantErr && tt.sleep == 0 { + t.Errorf("Put() error = %v, wantErr %v", err, tt.wantErr) + } + } + + time.Sleep(tt.sleep) + + got, err := c.Get(tt.args.bucket, tt.args.key) + if (err != nil) != tt.wantErr { + t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Get() got = %v, want %v", got, tt.want) + } + + if err := c.Close(); err != nil { + t.Errorf("Close() error = %v, wantErr %v", err, nil) + } + }) + } +} diff --git a/cache/put_test.go b/cache/put_test.go new file mode 100644 index 0000000..b2f237d --- /dev/null +++ b/cache/put_test.go @@ -0,0 +1,98 @@ +package cache + +import ( + "github.com/rs/zerolog" + "reflect" + "testing" + "time" +) + +func TestClient_Put(t *testing.T) { + type fields struct { + log zerolog.Logger + } + type args struct { + bucket string + key string + val []byte + ttl time.Duration + } + tests := []struct { + name string + fields fields + args args + sleep time.Duration + want []byte + wantErr bool + }{ + { + name: "with ttl", + fields: fields{ + log: zerolog.Logger{}, + }, + args: args{ + bucket: "put", + key: "test", + val: []byte("testing"), + ttl: 50 * time.Millisecond, + }, + want: []byte("testing"), + wantErr: false, + }, + { + name: "ttl timed out", + fields: fields{ + log: zerolog.Logger{}, + }, + args: args{ + bucket: "put", + key: "test", + val: []byte("testing"), + ttl: 1 * time.Second, + }, + sleep: 2 * time.Second, + want: nil, + wantErr: true, + }, + { + name: "no ttl", + fields: fields{ + log: zerolog.Logger{}, + }, + args: args{ + bucket: "put", + key: "test", + val: []byte("testing"), + ttl: 0, + }, + want: []byte("testing"), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ + log: tt.fields.log, + db: newDb(t, "nabarr_put"), + } + + if err := c.Put(tt.args.bucket, tt.args.key, tt.args.val, tt.args.ttl); (err != nil) != tt.wantErr && tt.sleep == 0 { + t.Errorf("Put() error = %v, wantErr %v", err, tt.wantErr) + } + + time.Sleep(tt.sleep) + + got, err := c.Get(tt.args.bucket, tt.args.key) + if (err != nil) != tt.wantErr { + t.Errorf("Put() get error = %v, wantErr %v", err, tt.wantErr) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Put() got = %v, want %v", got, tt.want) + } + + if err := c.Close(); err != nil { + t.Errorf("Close() error = %v, wantErr %v", err, nil) + } + }) + } +} diff --git a/cache/test.go b/cache/test.go new file mode 100644 index 0000000..faf76ab --- /dev/null +++ b/cache/test.go @@ -0,0 +1,24 @@ +package cache + +import ( + "github.com/xujiajun/nutsdb" + "os" + "path/filepath" + "testing" +) + +func newDb(t *testing.T, dir string) *nutsdb.DB { + db, err := nutsdb.Open(nutsdb.Options{ + Dir: filepath.Join(os.TempDir(), dir), + EntryIdxMode: nutsdb.HintKeyValAndRAMIdxMode, + SegmentSize: 8 * 1024 * 1024, + NodeNum: 1, + RWMode: nutsdb.FileIO, + SyncEnable: true, + StartFileLoadingMode: nutsdb.MMap, + }) + if err != nil { + t.Fatalf("newDb(dir: %v) open error: %v", dir, err) + } + return db +} diff --git a/go.mod b/go.mod index 5874f8b..f949d90 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/rs/zerolog v1.20.0 github.com/stretchr/testify v1.7.0 // indirect github.com/ulikunitz/xz v0.5.10 // indirect - github.com/xujiajun/nutsdb v0.5.0 + github.com/xujiajun/nutsdb v0.5.1-0.20210103130259-2812a595bc10 go.uber.org/atomic v1.7.0 // indirect go.uber.org/ratelimit v0.1.0 golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect diff --git a/go.sum b/go.sum index 9fdb147..9db9599 100644 --- a/go.sum +++ b/go.sum @@ -194,8 +194,8 @@ github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0o github.com/xujiajun/gorouter v1.2.0/go.mod h1:yJrIta+bTNpBM/2UT8hLOaEAFckO+m/qmR3luMIQygM= github.com/xujiajun/mmap-go v1.0.1 h1:7Se7ss1fLPPRW+ePgqGpCkfGIZzJV6JPq9Wq9iv/WHc= github.com/xujiajun/mmap-go v1.0.1/go.mod h1:CNN6Sw4SL69Sui00p0zEzcZKbt+5HtEnYUsc6BKKRMg= -github.com/xujiajun/nutsdb v0.5.0 h1:j/jM3Zw7Chg8WK7bAcKR0Xr7Mal47U1oJAMgySfDn9E= -github.com/xujiajun/nutsdb v0.5.0/go.mod h1:owdwN0tW084RxEodABLbO7h4Z2s9WiAjZGZFhRh0/1Q= +github.com/xujiajun/nutsdb v0.5.1-0.20210103130259-2812a595bc10 h1:Pk4tD6Odq88Hzc3U5QcKEZ9nRoSTST0Rau/5z6EcYrY= +github.com/xujiajun/nutsdb v0.5.1-0.20210103130259-2812a595bc10/go.mod h1:Q8FXi2zeQRluPpUl/CKQ6J7u/9gcI02J6cZp3owFLyA= github.com/xujiajun/utils v0.0.0-20190123093513-8bf096c4f53b h1:jKG9OiL4T4xQN3IUrhUpc1tG+HfDXppkgVcrAiiaI/0= github.com/xujiajun/utils v0.0.0-20190123093513-8bf096c4f53b/go.mod h1:AZd87GYJlUzl82Yab2kTjx1EyXSQCAfZDhpTo1SQC4k= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/media/trakt/media.go b/media/trakt/media.go index f82a8e4..a67db41 100644 --- a/media/trakt/media.go +++ b/media/trakt/media.go @@ -15,7 +15,7 @@ var ( func (c *Client) GetShow(tvdbId string) (*Show, error) { // prepare request - reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, fmt.Sprintf("/search/tvdb/%s", tvdbId)), + reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, "search", "tvdb", tvdbId), url.Values{ "type": []string{"show"}, "extended": []string{"full"}}) @@ -59,7 +59,7 @@ func (c *Client) GetShow(tvdbId string) (*Show, error) { func (c *Client) GetMovie(imdbId string) (*Movie, error) { // prepare request - reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, fmt.Sprintf("/search/imdb/%s", imdbId)), + reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, "search", "imdb", imdbId), url.Values{ "type": []string{"movie"}, "extended": []string{"full"}}) diff --git a/radarr/api.go b/radarr/api.go index 5e9d98a..f052b72 100644 --- a/radarr/api.go +++ b/radarr/api.go @@ -18,7 +18,7 @@ var ( func (c *Client) getSystemStatus() (*systemStatus, error) { // send request - resp, err := rek.Get(util.JoinURL(c.apiURL, "/system/status"), rek.Headers(c.apiHeaders), + resp, err := rek.Get(util.JoinURL(c.apiURL, "system", "status"), rek.Headers(c.apiHeaders), rek.Timeout(c.apiTimeout)) if err != nil { return nil, fmt.Errorf("request system status: %w", err) @@ -41,7 +41,7 @@ func (c *Client) getSystemStatus() (*systemStatus, error) { func (c *Client) getQualityProfileId(profileName string) (int, error) { // send request - resp, err := rek.Get(util.JoinURL(c.apiURL, "/profile"), rek.Headers(c.apiHeaders), + resp, err := rek.Get(util.JoinURL(c.apiURL, "profile"), rek.Headers(c.apiHeaders), rek.Timeout(c.apiTimeout)) if err != nil { return 0, fmt.Errorf("request quality profiles: %w", err) @@ -81,7 +81,7 @@ func (c *Client) lookupMediaItem(item *media.Item) (*lookupRequest, error) { } // prepare request - reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, "/movie/lookup"), + reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, "movie", "lookup"), url.Values{"term": []string{fmt.Sprintf("%s:%s", mdType, mdId)}}) if err != nil { return nil, fmt.Errorf("generate movie lookup request url: %w", err) @@ -143,7 +143,7 @@ func (c *Client) AddMediaItem(item *media.Item) error { } // send request - resp, err := rek.Post(util.JoinURL(c.apiURL, "/movie"), rek.Headers(c.apiHeaders), rek.Json(req), + resp, err := rek.Post(util.JoinURL(c.apiURL, "movie"), rek.Headers(c.apiHeaders), rek.Json(req), rek.Timeout(c.apiTimeout)) if err != nil { return fmt.Errorf("request add movie: %w", err) diff --git a/radarr/radarr.go b/radarr/radarr.go index 2f5f11a..6aca6c2 100644 --- a/radarr/radarr.go +++ b/radarr/radarr.go @@ -51,7 +51,7 @@ func New(c nabarr.PvrConfig, mode string, m *media.Client, cc *cache.Client) (*C if strings.Contains(strings.ToLower(c.URL), "/api") { apiURL = c.URL } else { - apiURL = util.JoinURL(c.URL, "/api") + apiURL = util.JoinURL(c.URL, "api") } // set api headers diff --git a/sonarr/api.go b/sonarr/api.go index fc470dc..0e58b05 100644 --- a/sonarr/api.go +++ b/sonarr/api.go @@ -18,7 +18,7 @@ var ( func (c *Client) getSystemStatus() (*systemStatus, error) { // send request - resp, err := rek.Get(util.JoinURL(c.apiURL, "/system/status"), rek.Headers(c.apiHeaders), + resp, err := rek.Get(util.JoinURL(c.apiURL, "system", "status"), rek.Headers(c.apiHeaders), rek.Timeout(c.apiTimeout)) if err != nil { return nil, fmt.Errorf("request system status: %w", err) @@ -41,7 +41,7 @@ func (c *Client) getSystemStatus() (*systemStatus, error) { func (c *Client) getQualityProfileId(profileName string) (int, error) { // send request - resp, err := rek.Get(util.JoinURL(c.apiURL, "/profile"), rek.Headers(c.apiHeaders), + resp, err := rek.Get(util.JoinURL(c.apiURL, "profile"), rek.Headers(c.apiHeaders), rek.Timeout(c.apiTimeout)) if err != nil { return 0, fmt.Errorf("request quality profiles: %w", err) @@ -71,7 +71,7 @@ func (c *Client) getQualityProfileId(profileName string) (int, error) { func (c *Client) lookupMediaItem(item *media.Item) (*lookupRequest, error) { // prepare request - reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, "/series/lookup"), + reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, "series", "lookup"), url.Values{"term": []string{fmt.Sprintf("tvdb:%s", item.TvdbId)}}) if err != nil { return nil, fmt.Errorf("generate series lookup request url: %w", err) @@ -133,7 +133,7 @@ func (c *Client) AddMediaItem(item *media.Item) error { } // send request - resp, err := rek.Post(util.JoinURL(c.apiURL, "/series"), rek.Headers(c.apiHeaders), rek.Json(req), + resp, err := rek.Post(util.JoinURL(c.apiURL, "series"), rek.Headers(c.apiHeaders), rek.Json(req), rek.Timeout(c.apiTimeout)) if err != nil { return fmt.Errorf("request add series: %w", err) diff --git a/sonarr/sonarr.go b/sonarr/sonarr.go index 4a8fc34..d2b1aca 100644 --- a/sonarr/sonarr.go +++ b/sonarr/sonarr.go @@ -51,7 +51,7 @@ func New(c nabarr.PvrConfig, mode string, m *media.Client, cc *cache.Client) (*C if strings.Contains(strings.ToLower(c.URL), "/api") { apiURL = c.URL } else { - apiURL = util.JoinURL(c.URL, "/api") + apiURL = util.JoinURL(c.URL, "api") } // set api headers diff --git a/util/default_test.go b/util/default_test.go new file mode 100644 index 0000000..85c8d2d --- /dev/null +++ b/util/default_test.go @@ -0,0 +1,83 @@ +package util + +import "testing" + +func TestAtof64(t *testing.T) { + type args struct { + val string + defaultVal float64 + } + tests := []struct { + name string + args args + want float64 + }{ + { + name: "expect whole value", + args: args{ + val: "1", + defaultVal: 0, + }, + want: 1.0, + }, + { + name: "expect non whole value", + args: args{ + val: "1.5", + defaultVal: 0, + }, + want: 1.5, + }, + { + name: "expect default", + args: args{ + val: "invalid", + defaultVal: 0, + }, + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Atof64(tt.args.val, tt.args.defaultVal); got != tt.want { + t.Errorf("Atof64() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAtoi(t *testing.T) { + type args struct { + val string + defaultVal int + } + tests := []struct { + name string + args args + want int + }{ + { + name: "expect value", + args: args{ + val: "5", + defaultVal: 0, + }, + want: 5, + }, + { + name: "expect default", + args: args{ + val: "invalid", + defaultVal: 2, + }, + want: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Atoi(tt.args.val, tt.args.defaultVal); got != tt.want { + t.Errorf("Atoi() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/util/hash_test.go b/util/hash_test.go new file mode 100644 index 0000000..4e64b2e --- /dev/null +++ b/util/hash_test.go @@ -0,0 +1,37 @@ +package util + +import "testing" + +func TestAsSHA256(t *testing.T) { + type args struct { + o interface{} + } + tests := []struct { + name string + args args + want string + }{ + { + name: "hash struct", + args: args{ + o: struct { + Name string + Surname string + Age int + }{ + Name: "John", + Surname: "Smith", + Age: 18, + }, + }, + want: "f20fe06d96e179073fc3eebac62d7a2edf3164f0c50524d82c0c6390013bbc4a", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := AsSHA256(tt.args.o); got != tt.want { + t.Errorf("AsSHA256() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/util/string_test.go b/util/string_test.go new file mode 100644 index 0000000..658d0ad --- /dev/null +++ b/util/string_test.go @@ -0,0 +1,29 @@ +package util + +import "testing" + +func TestStripNonAlphaNumeric(t *testing.T) { + type args struct { + value string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "remove trailing slash", + args: args{ + value: "tt1234567/", + }, + want: "tt1234567", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := StripNonAlphaNumeric(tt.args.value); got != tt.want { + t.Errorf("StripNonAlphaNumeric() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/util/url_test.go b/util/url_test.go new file mode 100644 index 0000000..150275f --- /dev/null +++ b/util/url_test.go @@ -0,0 +1,97 @@ +package util + +import ( + "net/url" + "testing" +) + +func TestJoinURL(t *testing.T) { + type args struct { + base string + paths []string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "single path", + args: args{ + base: "https://www.google.co.uk/", + paths: []string{"search"}, + }, + want: "https://www.google.co.uk/search", + }, + { + name: "multiple path", + args: args{ + base: "https://www.google.co.uk", + paths: []string{"search", "string"}, + }, + want: "https://www.google.co.uk/search/string", + }, + { + name: "multiple path with slashes", + args: args{ + base: "https://www.google.co.uk/", + paths: []string{"/search/", "/string/"}, + }, + want: "https://www.google.co.uk/search/string", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := JoinURL(tt.args.base, tt.args.paths...); got != tt.want { + t.Errorf("JoinURL() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestURLWithQuery(t *testing.T) { + type args struct { + base string + q url.Values + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "url with values", + args: args{ + base: JoinURL("https://api.trakt.tv", "search", "tvdb", "12345"), + q: url.Values{ + "extended": []string{"full"}, + "type": []string{"show"}, + }, + }, + want: "https://api.trakt.tv/search/tvdb/12345?extended=full&type=show", + wantErr: false, + }, + { + name: "url without values", + args: args{ + base: JoinURL("https://api.trakt.tv", "search", "tvdb", "12345"), + q: nil, + }, + want: "https://api.trakt.tv/search/tvdb/12345", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := URLWithQuery(tt.args.base, tt.args.q) + if (err != nil) != tt.wantErr { + t.Errorf("URLWithQuery() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("URLWithQuery() got = %v, want %v", got, tt.want) + } + }) + } +}