some changes:

- added unit tests
    - put all subcmds into one file
    - use io.Writer for output, better for testing
This commit is contained in:
2023-03-21 19:41:24 +01:00
parent b8816f910a
commit 0ed15a265d
12 changed files with 614 additions and 340 deletions

View File

@@ -27,6 +27,7 @@ import (
"github.com/schollz/progressbar/v3"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/cenophane/upctl/cfg"
"io"
"mime"
"os"
"path/filepath"
@@ -51,6 +52,9 @@ type ListParams struct {
const Maxwidth = 10
/*
Create a new request object for outgoing queries
*/
func Setup(c *cfg.Config, path string) *Request {
client := req.C()
if c.Debug {
@@ -86,9 +90,12 @@ func Setup(c *cfg.Config, path string) *Request {
}
return &Request{Url: c.Endpoint + path, R: R}
}
/*
Iterate over args, considering the elements are filenames, and add
them to the request.
*/
func GatherFiles(rq *Request, args []string) error {
for _, file := range args {
info, err := os.Stat(file)
@@ -120,7 +127,42 @@ func GatherFiles(rq *Request, args []string) error {
return nil
}
func UploadFiles(c *cfg.Config, args []string) error {
/*
Check HTTP Response Code and validate JSON status output, if
any. Turns'em into a regular error
*/
func HandleResponse(c *cfg.Config, resp *req.Response) error {
// we expect a json response, extract the error, if any
r := Response{}
if err := json.Unmarshal([]byte(resp.String()), &r); err != nil {
// text output!
r.Message = resp.String()
}
if c.Debug {
trace := resp.Request.TraceInfo()
fmt.Println(trace.Blame())
fmt.Println("----------")
fmt.Println(trace)
}
if !r.Success {
if len(r.Message) == 0 {
if resp.Err != nil {
return resp.Err
} else {
return errors.New("Unknown error")
}
} else {
return errors.New(r.Message)
}
}
return nil
}
func UploadFiles(w io.Writer, c *cfg.Config, args []string) error {
// setup url, req.Request, timeout handling etc
rq := Setup(c, "/file/")
@@ -150,46 +192,14 @@ func UploadFiles(c *cfg.Config, args []string) error {
return err
}
return RespondExtended(resp)
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondExtended(w, resp)
}
func HandleResponse(c *cfg.Config, resp *req.Response) error {
// we expect a json response, extract the error, if any
r := Response{}
if err := json.Unmarshal([]byte(resp.String()), &r); err != nil {
// text output!
r.Message = resp.String()
}
if c.Debug {
trace := resp.Request.TraceInfo()
fmt.Println(trace.Blame())
fmt.Println("----------")
fmt.Println(trace)
}
if !r.Success {
if len(r.Message) == 0 {
if resp.Err != nil {
return resp.Err
} else {
return errors.New("Unknown error")
}
} else {
return errors.New(r.Message)
}
}
// all right
if r.Message != "" {
fmt.Println(r.Message)
}
return nil
}
func List(c *cfg.Config, args []string) error {
func List(w io.Writer, c *cfg.Config, args []string) error {
rq := Setup(c, "/list/")
params := &ListParams{Apicontext: c.Apicontext}
@@ -201,10 +211,14 @@ func List(c *cfg.Config, args []string) error {
return err
}
return RespondTable(resp)
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondTable(w, resp)
}
func Delete(c *cfg.Config, args []string) error {
func Delete(w io.Writer, c *cfg.Config, args []string) error {
for _, id := range args {
rq := Setup(c, "/file/"+id+"/")
@@ -218,13 +232,17 @@ func Delete(c *cfg.Config, args []string) error {
return err
}
fmt.Printf("Upload %s successfully deleted.\n", id)
fmt.Fprintf(w, "Upload %s successfully deleted.\n", id)
}
return nil
}
func Describe(c *cfg.Config, args []string) error {
func Describe(w io.Writer, c *cfg.Config, args []string) error {
if len(args) == 0 {
return errors.New("No id provided!")
}
id := args[0] // we describe only 1 object
rq := Setup(c, "/upload/"+id+"/")
@@ -234,25 +252,37 @@ func Describe(c *cfg.Config, args []string) error {
return err
}
return RespondExtended(resp)
}
func Download(c *cfg.Config, args []string) error {
id := args[0]
// progres bar
bar := progressbar.Default(100)
callback := func(info req.DownloadInfo) {
if info.Response.Response != nil {
bar.Add(1)
}
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondExtended(w, resp)
}
func Download(w io.Writer, c *cfg.Config, args []string) error {
if len(args) == 0 {
return errors.New("No id provided!")
}
id := args[0]
rq := Setup(c, "/file/"+id+"/")
if !c.Silent {
// progres bar
bar := progressbar.Default(100)
callback := func(info req.DownloadInfo) {
if info.Response.Response != nil {
bar.Add(1)
}
}
rq.R.SetDownloadCallback(callback)
}
resp, err := rq.R.
SetOutputFile(id).
SetDownloadCallback(callback).
Get(rq.Url)
if err != nil {
@@ -278,7 +308,7 @@ func Download(c *cfg.Config, args []string) error {
return fmt.Errorf("\nUnable to rename file: " + err.Error())
}
fmt.Printf("%s successfully downloaded to file %s.", id, cleanfilename)
fmt.Fprintf(w, "%s successfully downloaded to file %s.", id, cleanfilename)
return nil
}

View File

@@ -19,10 +19,17 @@ package lib
import (
//"github.com/alecthomas/repr"
"bytes"
"fmt"
"github.com/jarcoal/httpmock"
"github.com/tlinden/cenophane/upctl/cfg"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
@@ -33,23 +40,64 @@ type Unit struct {
apikey string // set to something else than "token" to fail auth
wantfail bool // true: expect to fail
files []string // path relative to ./t/
sendcode int // for httpmock
sendjson string // struct to respond with
route string // dito
method string // method to use
expect string // regex used to parse the output
sendcode int // for httpmock
sendjson string // struct to respond with
sendfile string // bare file content to be sent
route string // dito
method string // method to use
}
// simulate our cenophane server
func Intercept(tt Unit) {
httpmock.RegisterResponder(tt.method, endpoint+tt.route,
func(request *http.Request) (*http.Response, error) {
respbody := fmt.Sprintf(tt.sendjson)
resp := httpmock.NewStringResponse(tt.sendcode, respbody)
resp.Header.Set("Content-Type", "application/json; charset=utf-8")
var resp *http.Response
if tt.sendfile != "" {
// simulate a file download
content, err := ioutil.ReadFile(tt.sendfile)
if err != nil {
panic(err) // should not happen
}
stat, err := os.Stat(tt.sendfile)
if err != nil {
panic(err) // should not happen as well
}
resp = httpmock.NewStringResponse(tt.sendcode, string(content))
resp.Header.Set("Content-Type", "text/markdown; charset=utf-8")
resp.Header.Set("Content-Length", strconv.Itoa(int(stat.Size())))
resp.Header.Set("Content-Disposition", "attachment; filename='t1'")
} else {
// simulate JSON response
resp = httpmock.NewStringResponse(tt.sendcode, tt.sendjson)
resp.Header.Set("Content-Type", "application/json; charset=utf-8")
}
return resp, nil
})
}
// execute the actual test
func Check(t *testing.T, tt Unit, w *bytes.Buffer, err error) {
testname := fmt.Sprintf("%s-%t", tt.name, tt.wantfail)
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
if tt.expect != "" {
got := strings.TrimSpace(w.String())
r := regexp.MustCompile(tt.expect)
if !r.MatchString(got) {
t.Errorf("%s failed! error: output does not match!\nexpect: %s\ngot:\n%s", testname, tt.expect, got)
}
}
}
func TestUploadFiles(t *testing.T) {
conf := &cfg.Config{
Mock: true,
@@ -70,7 +118,17 @@ func TestUploadFiles(t *testing.T) {
method: "POST",
},
{
name: "upload-nonexistent-file",
name: "upload-dir",
apikey: "token",
wantfail: false,
route: "/file/",
sendcode: 200,
sendjson: `{"success": true}`,
files: []string{"../t"}, // pwd is lib/ !
method: "POST",
},
{
name: "upload-catch-nonexistent-file",
apikey: "token",
wantfail: true,
route: "/file/",
@@ -80,7 +138,7 @@ func TestUploadFiles(t *testing.T) {
method: "POST",
},
{
name: "upload-unauth",
name: "upload-catch-no-access",
apikey: "token",
wantfail: true,
route: "/file/",
@@ -89,16 +147,31 @@ func TestUploadFiles(t *testing.T) {
files: []string{"../t/t1"},
method: "POST",
},
{
name: "upload-check-output",
apikey: "token",
wantfail: false,
route: "/file/",
sendcode: 200,
sendjson: `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"Download url: http://localhost:8080/download/cc2c965a/t1",
"code":200}`,
files: []string{"../t/t1"}, // pwd is lib/ !
method: "POST",
expect: "Expire: On first access",
},
}
for _, tt := range tests {
testname := fmt.Sprintf("UploadFiles-%s-%t", tt.name, tt.wantfail)
Intercept(tt)
err := UploadFiles(conf, tt.files)
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
for _, unit := range tests {
var w bytes.Buffer
Intercept(unit)
Check(t, unit, &w, UploadFiles(&w, conf, unit.files))
}
}
@@ -110,7 +183,18 @@ func TestList(t *testing.T) {
Silent: true,
}
listing := `{"uploads":[{"id":"c8dh","expire":"asap","file":"t1","members":["t1"],"uploaded":1679318969.6434112,"context":"foo","url":""}],"success":true,"message":"","code":200}`
listing := `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"",
"code":200}`
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "list",
@@ -121,17 +205,205 @@ func TestList(t *testing.T) {
sendjson: listing,
files: []string{},
method: "GET",
expect: `cc2c965a\s*asap\s*foo\s*2023-03-21 12:06:54`, // expect tabular output
},
{
name: "list-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/list/",
sendcode: 404,
sendjson: "",
files: []string{},
method: "GET",
},
{
name: "list-catch-no-access",
apikey: "token",
wantfail: true,
route: "/list/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{},
method: "GET",
},
}
for _, tt := range tests {
testname := fmt.Sprintf("List-%s-%t", tt.name, tt.wantfail)
Intercept(tt)
err := List(conf, []string{})
for _, unit := range tests {
var w bytes.Buffer
Intercept(unit)
Check(t, unit, &w, List(&w, conf, []string{}))
}
}
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
func TestDescribe(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listing := `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"",
"code":200}`
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "describe",
apikey: "token",
wantfail: false,
route: "/upload/",
sendcode: 200,
sendjson: listing,
files: []string{"cc2c965a"},
method: "GET",
expect: `Uploaded: 2023-03-21 12:06:54.890501888`,
},
{
name: "describe-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/upload/",
sendcode: 200,
sendjson: "",
files: []string{"cc2c965a"},
method: "GET",
},
{
name: "describe-catch-no-access",
apikey: "token",
wantfail: true,
route: "/upload/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "GET",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0] + "/"
Intercept(unit)
Check(t, unit, &w, Describe(&w, conf, unit.files))
}
}
func TestDelete(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "delete",
apikey: "token",
wantfail: false,
route: "/file/",
sendcode: 200,
sendjson: `{"success":true,"message":"","code":200}`,
files: []string{"cc2c965a"},
method: "DELETE",
expect: `Upload cc2c965a successfully deleted`,
},
{
name: "delete-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/file/",
sendcode: 200,
sendjson: "",
files: []string{"cc2c965a"},
method: "DELETE",
},
{
name: "delete-catch-no-access",
apikey: "token",
wantfail: true,
route: "/file/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "DELETE",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0] + "/"
Intercept(unit)
Check(t, unit, &w, Delete(&w, conf, unit.files))
}
}
func TestDownload(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "download",
apikey: "token",
wantfail: false,
route: "/file/",
sendcode: 200,
sendfile: "../t/t1",
files: []string{"cc2c965a"},
method: "GET",
expect: `cc2c965a successfully downloaded to file t1`,
},
{
name: "download-catch-empty-response",
apikey: "token",
wantfail: true,
route: "/file/",
sendcode: 200,
files: []string{"cc2c965a"},
method: "GET",
},
{
name: "download-catch-no-access",
apikey: "token",
wantfail: true,
route: "/file/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "GET",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0] + "/"
Intercept(unit)
Check(t, unit, &w, Download(&w, conf, unit.files))
if unit.sendfile != "" {
file := filepath.Base(unit.sendfile)
if _, err := os.Stat(file); err == nil {
os.Remove(file)
}
}
}
}

View File

@@ -24,7 +24,8 @@ import (
"github.com/imroc/req/v3"
"github.com/olekukonko/tablewriter"
"github.com/tlinden/cenophane/common"
"os"
"io"
"strings"
"time"
)
@@ -41,8 +42,9 @@ func prepareExpire(expire string, start common.Timestamp) string {
}
// generic table writer
func WriteTable(headers []string, data [][]string) {
table := tablewriter.NewWriter(os.Stdout)
func WriteTable(w io.Writer, headers []string, data [][]string) {
tableString := &strings.Builder{}
table := tablewriter.NewWriter(tableString)
table.SetHeader(headers)
table.AppendBulk(data)
@@ -60,22 +62,24 @@ func WriteTable(headers []string, data [][]string) {
table.SetNoWhiteSpace(true)
table.Render()
fmt.Fprintln(w, tableString.String())
}
// output like psql \x
func WriteExtended(uploads *common.Uploads) {
func WriteExtended(w io.Writer, uploads *common.Uploads) {
format := fmt.Sprintf("%%%ds: %%s\n", Maxwidth)
// we shall only have 1 element, however, if we ever support more, here we go
for _, entry := range uploads.Entries {
expire := prepareExpire(entry.Expire, entry.Uploaded)
fmt.Printf(format, "Id", entry.Id)
fmt.Printf(format, "Expire", expire)
fmt.Printf(format, "Context", entry.Context)
fmt.Printf(format, "Uploaded", entry.Uploaded)
fmt.Printf(format, "Filename", entry.File)
fmt.Printf(format, "Url", entry.Url)
fmt.Println()
fmt.Fprintf(w, format, "Id", entry.Id)
fmt.Fprintf(w, format, "Expire", expire)
fmt.Fprintf(w, format, "Context", entry.Context)
fmt.Fprintf(w, format, "Uploaded", entry.Uploaded)
fmt.Fprintf(w, format, "Filename", entry.File)
fmt.Fprintf(w, format, "Url", entry.Url)
fmt.Fprintln(w)
}
}
@@ -95,14 +99,14 @@ func GetUploadsFromResponse(resp *req.Response) (*common.Uploads, error) {
}
// turn the Uploads{} struct into a table and print it
func RespondTable(resp *req.Response) error {
func RespondTable(w io.Writer, resp *req.Response) error {
uploads, err := GetUploadsFromResponse(resp)
if err != nil {
return err
}
if uploads.Message != "" {
fmt.Println(uploads.Message)
fmt.Fprintln(w, uploads.Message)
}
// tablewriter
@@ -113,23 +117,23 @@ func RespondTable(resp *req.Response) error {
})
}
WriteTable([]string{"ID", "EXPIRE", "CONTEXT", "UPLOADED"}, data)
WriteTable(w, []string{"ID", "EXPIRE", "CONTEXT", "UPLOADED"}, data)
return nil
}
// turn the Uploads{} struct into xtnd output and print it
func RespondExtended(resp *req.Response) error {
func RespondExtended(w io.Writer, resp *req.Response) error {
uploads, err := GetUploadsFromResponse(resp)
if err != nil {
return err
}
if uploads.Message != "" {
fmt.Println(uploads.Message)
fmt.Fprintln(w, uploads.Message)
}
WriteExtended(uploads)
WriteExtended(w, uploads)
return nil
}