From 97a28d7239dc6b2629de94b41b7e108863691baf Mon Sep 17 00:00:00 2001 From: Sinuhe Tellez Date: Thu, 6 May 2021 02:48:02 -0400 Subject: [PATCH] add unittests --- .github/workflows/go.yml | 2 +- .github/workflows/release.yml | 3 + go.mod | 1 + go.sum | 11 ++ internal/service/service.go | 158 +++++++++++++++++++ internal/service/service_test.go | 115 ++++++++++++++ internal/service/testdata/mybook/mybook.epub | Bin 0 -> 2295 bytes internal/service/testdata/mybook/mybook.pdf | Bin 0 -> 7250 bytes internal/service/testdata/mybook/mybook.txt | 1 + main.go | 145 +---------------- main_test.go | 57 +++++++ opds/feed_builder.go | 6 + 12 files changed, 357 insertions(+), 142 deletions(-) create mode 100644 internal/service/service.go create mode 100644 internal/service/service_test.go create mode 100644 internal/service/testdata/mybook/mybook.epub create mode 100644 internal/service/testdata/mybook/mybook.pdf create mode 100644 internal/service/testdata/mybook/mybook.txt create mode 100644 main_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 1bd0a1f..a4021c0 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v2 with: - go-version: 1.15 + go-version: 1.16 - name: Build run: go build -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7add829..1dce362 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,9 @@ jobs: uses: actions/setup-go@v2 with: go-version: 1.16 + - + name: Test + run: go test -v ./... - name: Run GoReleaser uses: goreleaser/goreleaser-action@v2 diff --git a/go.mod b/go.mod index eba05d3..9b3ea43 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ go 1.12 require ( github.com/lann/builder v0.0.0-20150808151131-f22ce00fd939 github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/stretchr/testify v1.7.0 golang.org/x/tools v0.0.0-20170217234718-8e779ee0a450 ) diff --git a/go.sum b/go.sum index 1a9fbcb..1ed6fec 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,17 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/lann/builder v0.0.0-20150808151131-f22ce00fd939 h1:yZJImkCmVI6d1uJ9KRRf/96YbFLDQ/hhs6Xt9Z3OBXI= github.com/lann/builder v0.0.0-20150808151131-f22ce00fd939/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +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= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/tools v0.0.0-20170217234718-8e779ee0a450 h1:qbbvkCEu5ZgZKpHV38z/uXcloRX6fn/EgiGMW/9eluc= golang.org/x/tools v0.0.0-20170217234718-8e779ee0a450/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.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..4139960 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,158 @@ +//package service provides a http handler that reads the path in the request.url and returns +// an xml document that follows the OPDS 1.1 standard +// https://specs.opds.io/opds-1.1.html +package service + +import ( + "bytes" + "encoding/xml" + "io/ioutil" + "log" + "mime" + "net/http" + "os" + "path" + "path/filepath" + "time" + + "github.com/dubyte/dir2opds/opds" + "golang.org/x/tools/blog/atom" +) + +func init() { + _ = mime.AddExtensionType(".mobi", "application/x-mobipocket-ebook") + _ = mime.AddExtensionType(".epub", "application/epub+zip") + _ = mime.AddExtensionType(".cbz", "application/x-cbz") + _ = mime.AddExtensionType(".cbr", "application/x-cbr") + _ = mime.AddExtensionType(".fb2", "text/fb2+xml") +} + +type OPDS struct { + DirRoot string + Author string + AuthorEmail string + AuthorURI string +} + +var TimeNowFunc = timeNow + +func (s OPDS) Handler(w http.ResponseWriter, req *http.Request) error { + fPath := path.Join(s.DirRoot, req.URL.Path) + + log.Printf("fPath:'%s'", fPath) + + fi, err := os.Stat(fPath) + if err != nil { + return err + } + + if isFile(fi) { + http.ServeFile(w, req, fPath) + return nil + } + + content, err := s.getContent(req, fPath) + if err != nil { + return err + } + + content = append([]byte(xml.Header), content...) + http.ServeContent(w, req, "feed.xml", TimeNowFunc(), bytes.NewReader(content)) + return nil +} + +func (s OPDS) getContent(req *http.Request, dirpath string) (result []byte, err error) { + feed := s.makeFeed(dirpath, req) + if getPathType(dirpath) == pathTypeDirOfFiles { + acFeed := &opds.AcquisitionFeed{&feed, "http://purl.org/dc/terms/", "http://opds-spec.org/2010/catalog"} + result, err = xml.MarshalIndent(acFeed, " ", " ") + } else { + result, err = xml.MarshalIndent(feed, " ", " ") + } + return +} + +const navigationType = "application/atom+xml;profile=opds-catalog;kind=navigation" + +func (s OPDS) makeFeed(dirpath string, req *http.Request) atom.Feed { + feedBuilder := opds.FeedBuilder. + ID(req.URL.Path). + Title("Catalog in " + req.URL.Path). + Author(opds.AuthorBuilder.Name(s.Author).Email(s.AuthorEmail).URI(s.AuthorURI).Build()). + Updated(TimeNowFunc()). + AddLink(opds.LinkBuilder.Rel("start").Href("/").Type(navigationType).Build()) + + fis, _ := ioutil.ReadDir(dirpath) + for _, fi := range fis { + pathType := getPathType(path.Join(dirpath, fi.Name())) + feedBuilder = feedBuilder. + AddEntry(opds.EntryBuilder. + ID(req.URL.Path + fi.Name()). + Title(fi.Name()). + Updated(TimeNowFunc()). + Published(TimeNowFunc()). + AddLink(opds.LinkBuilder. + Rel(getRel(fi.Name(), pathType)). + Title(fi.Name()). + Href(getHref(req, fi.Name())). + Type(getType(fi.Name(), pathType)). + Build()). + Build()) + } + return feedBuilder.Build() +} + +func getRel(name string, pathType int) string { + if pathType == pathTypeDirOfFiles || pathType == pathTypeDirOfDirs { + return "subsection" + } + + ext := filepath.Ext(name) + if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" { + return "http://opds-spec.org/image/thumbnail" + } + + // mobi, epub, etc + return "http://opds-spec.org/acquisition" +} + +func getType(name string, pathType int) string { + if pathType == pathTypeFile { + return mime.TypeByExtension(filepath.Ext(name)) + } + return "application/atom+xml;profile=opds-catalog;kind=acquisition" +} + +func getHref(req *http.Request, name string) string { + return path.Join(req.URL.RequestURI(), name) +} + +const ( + pathTypeFile = iota + pathTypeDirOfDirs + pathTypeDirOfFiles +) + +func getPathType(dirpath string) int { + fi, _ := os.Stat(dirpath) + if isFile(fi) { + return pathTypeFile + } + + fis, _ := ioutil.ReadDir(dirpath) + for _, fi := range fis { + if isFile(fi) { + return pathTypeDirOfFiles + } + } + // Directory of directories + return pathTypeDirOfDirs +} + +func isFile(fi os.FileInfo) bool { + return !fi.IsDir() +} + +func timeNow() time.Time { + return time.Now() +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..e070cad --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,115 @@ +package service_test + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/dubyte/dir2opds/internal/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandler(t *testing.T) { + // pre-setup + nowFn := service.TimeNowFunc + defer func() { + service.TimeNowFunc = nowFn + }() + + tests := map[string]struct { + input string + want string + WantedContentType string + }{ + "feed (dir of folders )": {input: "/", want: feed, WantedContentType: "application/xml"}, + "acquisitionFeed(dir of files)": {input: "/mybook", want: acquisitionFeed, WantedContentType: "application/xml"}, + "servingAFile": {input: "/mybook/mybook.txt", want: "Fixture", WantedContentType: "text/plain; charset=utf-8"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // setup + s := service.OPDS{"testdata", "", "", ""} + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tc.input, nil) + service.TimeNowFunc = func() time.Time { + return time.Date(2020, 05, 25, 00, 00, 00, 0, time.UTC) + } + + // act + err := s.Handler(w, req) + require.NoError(t, err) + + // post act + resp := w.Result() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // verify + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, tc.WantedContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, tc.want, string(body)) + }) + } + +} + +var feed = ` + + Catalog in / + / + + 2020-05-25T00:00:00+00:00 + + + + + emptyFolder + /emptyFolder + + 2020-05-25T00:00:00+00:00 + 2020-05-25T00:00:00+00:00 + + + mybook + /mybook + + 2020-05-25T00:00:00+00:00 + 2020-05-25T00:00:00+00:00 + + ` + +var acquisitionFeed = ` + + Catalog in /mybook + /mybook + + 2020-05-25T00:00:00+00:00 + + + + + mybook.epub + /mybookmybook.epub + + 2020-05-25T00:00:00+00:00 + 2020-05-25T00:00:00+00:00 + + + mybook.pdf + /mybookmybook.pdf + + 2020-05-25T00:00:00+00:00 + 2020-05-25T00:00:00+00:00 + + + mybook.txt + /mybookmybook.txt + + 2020-05-25T00:00:00+00:00 + 2020-05-25T00:00:00+00:00 + + ` diff --git a/internal/service/testdata/mybook/mybook.epub b/internal/service/testdata/mybook/mybook.epub new file mode 100644 index 0000000000000000000000000000000000000000..b675c762daeaebdebdb644abbee9855be66ed30f GIT binary patch literal 2295 zcmah~2{@GN9v{nOvMWoI22&hr7=upANsLUk%3Na$DKygzW?@FhXskJg$~J|OoRs~b zEIA#FjxBMrCi}^HLK22D(d~9WH%rHJyZ8N`|MUId?|Xj#=lw75?}xP!5tRplARrJ| zC&!NJQJ@X%K&u9P0+C5%JcCKYd(ddUBrgvJiAvGM(*isZK_nV>4GH-Vvdc$!$_GI3 z08<9c7Gs9R>CzcYUp#&7Ma1J7NH045r#Q5DBuWw^ID$c9{fcs}$bJz6H+OZW)>irW zjaw_J9=jtLaZ0`E{ZElG^8vcI>>J+8t~zOKYpUI-wP45NlyOgYo@-JpZtlD7CAINd zpVgtUZ7kP}?^nX#N=X!!7$h&&5nYlkJlGXORX2Ct+MQ)L#GjtUt{W|Pabc#zeaiSBiP*#er%fGYq@{X*SKXe zK;6IY)^?mEPGx+;xfj9J4h4Nl4g&eo03e@@r3QFw4ZwS?HVtUO7YBtx>8)iOkM^%u z1G-=g&aAE_DHh>qmyV!=K%R*37!mou`W^?v{c^g%S3aDHVXG$d&amUe4^qqJrsxj8YmuCwD9-&;9ybiOGNO*iKFmp=^iaXghIE-0?ET##FXwPhpk*PJUq+J&_1S6{Vonhq6Vr$1+B2 zMI9@9W1b|?8|c|L=9GG*jPOx*7hi~VO4kRju<;*hAz{_M@7eQkeBr#el+m=2#D1dS zxpjK^m2}c|EevzA0)9t7zc68R@Eq1g4DzqEHn~}#G87@uTJ1nEYCbYW2a21Yes?v@;qnwI+x=3ugKlC8yWuSA zg{1Pr+z(vsW(uynX-7*-nqZ=SZ^fGe{Xn7KTLU@TaiS76zqlw@+4PUsKccnHmdaS| z>n{5f-En5I;7SLvm?$oS#E1UYmr6Z0pBO>L{c7QR=(egfyJ;#X+WXPC0o;*Cxw?~> z*&XR)

z>agTLgboDDg`9%Z8T8FpYqN=G0>hm(o+WDaDWUVsug4T+ZnkgR`;hEc$ zMP^}LV8zdD2|3+*o>q^C+_fhM+@mq~9qNX$Ou|8DvnApka&U2N{ds2Jl&b6>;SB?) zxVo@epGowCyF!lERd0yzf}scpWaMwe3(znwT~jCW@x^fL`TP(nCv)~ zmCkV>C;oD}zq%R5(iP7Pd$3ndC$qn`y5#Kd!7jrTs!}cZybZ?$4ObeQ@VRIWYm11_ zZod0^0p^U{>oTON=zIqKN0SJJ7{&K}p~60Ay_|IX&e2|KKd`!s~NH?4A+cSz3j7`*TcjGA;RK(&L7TFQ?p~ zjLHLjR6}-Vpa}HAVFotPw+Pu~kRc3T!f!|;kl%y@H%|$8f}Gcov=-)@wRWx8`cI4`~!i+ NfTsz_7Am&7`!}ELia-DW literal 0 HcmV?d00001 diff --git a/internal/service/testdata/mybook/mybook.pdf b/internal/service/testdata/mybook/mybook.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8476a28ac0347db57c6b7c59bd1a6b368c7c09f0 GIT binary patch literal 7250 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