diff --git a/handler/fs/fs.go b/handler/fs/fs.go new file mode 100644 index 0000000..6a8c18a --- /dev/null +++ b/handler/fs/fs.go @@ -0,0 +1,84 @@ +package fs + +import ( + "io" + "path" + "reactor-crw" +) + +// PathResolver defines a simple interface to resolve path for content +// before saving it. +type pathResolver interface { + // CreateFolder creates a new folder in the "current" dir. The current + // directory is specified in Config.SavePath provided by a user. If dir + // with such name already exists it'll skip creation step. + CreateFolder(name string) error + + // CreateFile creates a new file for downloaded content in the directory + // created by the CreateFolder function and returns the corresponding + // io.WriteCloser. If a file with such a name already exists then it'll + // be overwritten. + CreateFile(name string) (io.WriteCloser, error) + + // Remove removes a file or folder by its name. If provided path does not + // exist it'll be skipped. + Remove(name string) +} + +// FileSaver defines ContentHandler implementation that will download content +// and save it to the host's file system. It resolves the corresponding file +// path with PathResolver. +type FileSaver struct { + pr pathResolver + t reactor_crw.Transport +} + +// NewFileSaver creates a new FileSaver instance along with a new folder that +// will act as a context for a new instance. All files will be processed within +// this new folder. +func NewFileSaver(pr pathResolver, t reactor_crw.Transport, baseFolder string) (*FileSaver, error) { + err := pr.CreateFolder(baseFolder) + if err != nil { + return nil, err + } + + return &FileSaver{ + pr: pr, + t: t, + }, nil +} + +// Process downloads content by the corresponding URL by making an HTTP request +// and saves the result to the file system. In case of error during saving the +// content the corresponding file will be deleted from the file system. +func (f *FileSaver) Process(url string, progress chan<- int, e chan<- error) { + defer func() { + progress <- 1 + }() + + data, err := f.t.FetchData(url) + if err != nil { + e <- err + return + } + + defer func(b io.ReadCloser) { + _ = b.Close() + }(data) + + file, err := f.pr.CreateFile(path.Base(url)) + if err != nil { + e <- err + return + } + + defer func(f io.WriteCloser) { + _ = f.Close() + }(file) + + _, err = io.Copy(file, data) + if err != nil { + f.pr.Remove(path.Base(url)) + e <- err + } +} diff --git a/handler/fs/fs_test.go b/handler/fs/fs_test.go new file mode 100644 index 0000000..43daa2d --- /dev/null +++ b/handler/fs/fs_test.go @@ -0,0 +1,130 @@ +//go:build unit +// +build unit + +package fs_test + +import ( + "errors" + "io" + "io/ioutil" + "os" + "reactor-crw/handler/fs" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type pathResolverMock struct { + mock.Mock +} + +func (m *pathResolverMock) CreateFolder(name string) error { + args := m.Called(name) + return args.Error(0) +} + +func (m *pathResolverMock) CreateFile(name string) (io.WriteCloser, error) { + args := m.Called(name) + return args.Get(0).(io.WriteCloser), args.Error(1) +} + +func (m *pathResolverMock) Remove(name string) { + _ = m.Called(name) +} + +type transportMock struct { + mock.Mock +} + +func (m *transportMock) FetchData(url string) (io.ReadCloser, error) { + args := m.Called(url) + return args.Get(0).(io.ReadCloser), args.Error(1) +} + +func TestNewFileSaver(t *testing.T) { + pr := pathResolverMock{} + trp := transportMock{} + + t.Log("Given the need to create a file saver handler.") + { + t.Log("When baseFolder is incorrect.") + { + pr.On("CreateFolder", "baseFolder").Return(errors.New("error")) + _, err := fs.NewFileSaver(&pr, &trp, "baseFolder") + require.Error(t, err, "Expected an error during create folder") + pr.ExpectedCalls = []*mock.Call{} + } + + t.Log("When all data correct.") + { + pr.On("CreateFolder", "baseFolder").Return(nil) + _, err := fs.NewFileSaver(&pr, &trp, "baseFolder") + require.NoError(t, err, "Wasn't expected an error on new creating file saver") + } + } +} + +func TestFileSaver_Process(t *testing.T) { + tmlFile, _ := ioutil.TempFile(os.TempDir(), "file-title.txt") + + pr := pathResolverMock{} + pr.On("CreateFolder", "baseFolder").Return(nil) + pr.On("Remove", mock.Anything) + + trp := transportMock{} + + p := make(chan int, 1) + e := make(chan error, 1) + + fileSaver, _ := fs.NewFileSaver(&pr, &trp, "baseFolder") + + t.Log("Given the need to process data.") + { + t.Log("When transport returns an error.") + { + trp.On("FetchData", "file-title.txt").Return(tmlFile, errors.New("error")).Once() + + fileSaver.Process("file-title.txt", p, e) + require.Error(t, <-e, "Expected an error during create file") + <-p + } + + t.Log("When path resolver returns an error.") + { + trp.On("FetchData", "file-title.txt").Return(tmlFile, nil).Once() + pr.On("CreateFile", "file-title.txt").Return(tmlFile, errors.New("error")).Once() + + fileSaver.Process("file-title.txt", p, e) + require.Error(t, <-e, "Expected an error during create file") + <-p + } + + t.Log("When cannot save the data.") + { + trp.On("FetchData", "file-title.txt").Return(tmlFile, nil).Once() + pr.On("CreateFile", "file-title.txt").Return(tmlFile, nil).Once() + pr.On("Remove", mock.Anything) + + fileSaver.Process("file-title.txt", p, e) + require.Error(t, <-e, "Expected an error file copying") + <-p + } + + t.Log("When all data correct.") + { + tmlFile, _ = ioutil.TempFile(os.TempDir(), "new-file-title.txt") + + trp.On("FetchData", "new-file-title.txt").Return(tmlFile, nil).Once() + pr.On("CreateFile", "new-file-title.txt").Return(tmlFile, nil).Once() + + fileSaver.Process("new-file-title.txt", p, e) + select { + case err := <-e: + require.NoError(t, err, "Wasn't expected an error on new file process") + case <-p: + return + } + } + } +}