diff --git a/file_facade_integration_test.go b/file_facade_integration_test.go new file mode 100644 index 0000000..f2fbcbf --- /dev/null +++ b/file_facade_integration_test.go @@ -0,0 +1,131 @@ +//go:build integration + +package onlyoffice + +import ( + "bytes" + "context" + "strconv" + "testing" + "time" +) + +// TestIntegrationFacadeCRUD drives the whole operation set through the composed +// facade c.Files(): folder create, upload, stat, list, rename, move, copy, +// delete. Writes must go to REST (the default writeOrder), reads follow +// readOrder (REST when no SQL backend is registered) and every returned Entry +// must report its provider. Destructive — throwaway project, cleaned up. +func TestIntegrationFacadeCRUD(t *testing.T) { + c := liveClient(t) + t.Cleanup(func() { cleanupTestProjects(t, c) }) + ctx := context.Background() + + suffix := time.Now().UTC().Format("20060102-150405") + project, err := c.CreateProject(NewProjectRequest{ + Title: testProjectPrefix + "facade-" + suffix, + Description: "go-onlyoffice facade CRUD integration", + }) + if err != nil { + t.Fatalf("CreateProject: %v", err) + } + if project.ID == nil { + t.Fatal("created project without id") + } + root, err := c.projectFolderID(ctx, strconv.Itoa(*project.ID)) + if err != nil { + t.Fatalf("projectFolderID: %v", err) + } + + f := c.Files() + if got := f.Write().Name(); got != ProviderREST { + t.Fatalf("Write().Name() = %q, want %q", got, ProviderREST) + } + if got := f.Read().Name(); got != ProviderREST { + t.Fatalf("Read().Name() = %q, want %q (no SQL backend registered)", got, ProviderREST) + } + + src, err := f.CreateFolder(ctx, root, "facade-src-"+suffix) + if err != nil { + t.Fatalf("CreateFolder src: %v", err) + } + if src.Kind != Folder || src.ID == "" { + t.Fatalf("created src folder: %+v", src) + } + if src.Provider != ProviderREST { + t.Fatalf("CreateFolder provider = %q, want %q", src.Provider, ProviderREST) + } + dst, err := f.CreateFolder(ctx, root, "facade-dst-"+suffix) + if err != nil { + t.Fatalf("CreateFolder dst: %v", err) + } + if dst.Provider != ProviderREST { + t.Fatalf("CreateFolder dst provider = %q, want %q", dst.Provider, ProviderREST) + } + t.Cleanup(func() { + if err := c.DeleteDavItems(ctx, []string{src.ID, dst.ID}, nil); err != nil { + t.Logf("cleanup folders: %v", err) + } + }) + + content := []byte("facade crud " + suffix + "\n") + up, err := f.Upload(ctx, src.ID, "facade-doc-"+suffix+".txt", bytes.NewReader(content)) + if err != nil { + t.Fatalf("Upload: %v", err) + } + if up.Kind != File || up.ID == "" { + t.Fatalf("uploaded entry: %+v", up) + } + if up.Provider != ProviderREST { + t.Fatalf("Upload provider = %q, want %q (write order REST first)", up.Provider, ProviderREST) + } + if !waitEntry(ctx, f, src.ID, up.ID, 15*time.Second) { + t.Fatalf("uploaded %s not listed in src", up.ID) + } + + st, err := f.Stat(ctx, up.ID) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if st.ID != up.ID || st.Kind != File { + t.Fatalf("Stat = %+v", st) + } + if st.Provider != ProviderREST { + t.Fatalf("Stat provider = %q, want %q (read order REST)", st.Provider, ProviderREST) + } + + list, err := f.List(ctx, src.ID) + if err != nil { + t.Fatalf("List: %v", err) + } + if e := entryByID(list, up.ID); e == nil { + t.Fatalf("uploaded %s not in List(src)", up.ID) + } else if e.Provider != ProviderREST { + t.Fatalf("List provider = %q, want %q", e.Provider, ProviderREST) + } + + renamed := "facade-renamed-" + suffix + ".txt" + renameEventually(t, ctx, f, up.ID, renamed) + + moveEventually(t, ctx, f, up.ID, dst.ID) + if !waitEntry(ctx, f, dst.ID, up.ID, 20*time.Second) { + t.Fatalf("moved file %s not in dst", up.ID) + } + + copied := copyEventually(t, ctx, f, up.ID, src.ID, 20*time.Second) + if copied == nil { + t.Fatalf("no copy found in src after Copy") + } + if copied.Provider != ProviderREST { + t.Fatalf("Copy provider = %q, want %q", copied.Provider, ProviderREST) + } + + if err := f.Delete(ctx, []string{up.ID, copied.ID}); err != nil { + t.Fatalf("Delete: %v", err) + } + if !waitNoEntry(ctx, f, dst.ID, up.ID, 20*time.Second) { + t.Fatalf("file %s still present in dst after delete", up.ID) + } + if !waitNoEntry(ctx, f, src.ID, copied.ID, 20*time.Second) { + t.Fatalf("copy %s still present in src after delete", copied.ID) + } +} diff --git a/file_facade_test.go b/file_facade_test.go index b424e97..4be4a74 100644 --- a/file_facade_test.go +++ b/file_facade_test.go @@ -250,6 +250,36 @@ func TestFileClientMySQLStoreIsPreferredForReads(t *testing.T) { } } +// TestFileClientWriteToReadOnlyStore guarantees the facade surfaces ErrReadOnly +// when the configured write backend is the read-only SQL store. +func TestFileClientWriteToReadOnlyStore(t *testing.T) { + pg := &pgStore{driver: ProviderPG} + f := newFacadeTestClient( + map[string]FileStore{ProviderREST: &fakeStore{name: ProviderREST}, ProviderPG: pg}, + []string{ProviderPG, ProviderREST}, + []string{ProviderPG, ProviderREST}, + ) + ctx := context.Background() + if _, err := f.CreateFolder(ctx, "1", "x"); !errors.Is(err, ErrReadOnly) { + t.Errorf("CreateFolder err = %v", err) + } + if _, err := f.Upload(ctx, "1", "x", strings.NewReader("x")); !errors.Is(err, ErrReadOnly) { + t.Errorf("Upload err = %v", err) + } + if err := f.Move(ctx, []string{"1"}, "2"); !errors.Is(err, ErrReadOnly) { + t.Errorf("Move err = %v", err) + } + if err := f.Copy(ctx, []string{"1"}, "2"); !errors.Is(err, ErrReadOnly) { + t.Errorf("Copy err = %v", err) + } + if err := f.Rename(ctx, "1", "x"); !errors.Is(err, ErrReadOnly) { + t.Errorf("Rename err = %v", err) + } + if err := f.Delete(ctx, []string{"1"}); !errors.Is(err, ErrReadOnly) { + t.Errorf("Delete err = %v", err) + } +} + func TestFileClientSearchSelection(t *testing.T) { f := &FileClient{searchers: map[string]Searcher{}, searchOrder: []string{ProviderES}} _, err := f.Search() diff --git a/file_store_integration_test.go b/file_store_integration_test.go index 4533cf8..2b91a57 100644 --- a/file_store_integration_test.go +++ b/file_store_integration_test.go @@ -10,10 +10,11 @@ import ( "time" ) -// TestIntegrationFileStores runs the same operation set (create folder, upload, -// list, stat, download, move, copy, rename, delete) through the REST and DAV -// FileStore adapters against a throwaway project Documents folder. Destructive -// — only run against instances you own. +// TestIntegrationFileStores runs the same operation set through the REST and +// DAV FileStore adapters against a throwaway project Documents folder: file +// create/upload/list/stat/download/move/copy/rename/delete and folder +// create/stat/list/rename/move/delete. Destructive — only run against +// instances you own. // // The Documents fileops API is asynchronous: a move/copy/delete is accepted // immediately and becomes visible a moment later, so effects are polled. @@ -102,10 +103,7 @@ func testFileStoreOps(t *testing.T, ctx context.Context, c *Client, store FileSt t.Fatalf("moved file %s not in dst", up.ID) } - if err := store.Copy(ctx, []string{up.ID}, src.ID); err != nil { - t.Fatalf("Copy: %v", err) - } - copied := waitOtherFile(ctx, store, src.ID, up.ID, 20*time.Second) + copied := copyEventually(t, ctx, store, up.ID, src.ID, 20*time.Second) if copied == nil { t.Fatalf("no copy found in src after Copy") } @@ -122,6 +120,67 @@ func testFileStoreOps(t *testing.T, ctx context.Context, c *Client, store FileSt if !waitNoEntry(ctx, store, src.ID, copied.ID, 20*time.Second) { t.Fatalf("copy %s still present in src after delete", copied.ID) } + + // --- CRUD on the folders themselves, reusing the throwaway src/dst --- + // A child file lets us prove it survives the folder rename and move. + child, err := store.Upload(ctx, src.ID, "child-"+suffix+".txt", bytes.NewReader(content)) + if err != nil { + t.Fatalf("Upload child: %v", err) + } + if !waitEntry(ctx, store, src.ID, child.ID, 15*time.Second) { + t.Fatalf("child %s not listed in src", child.ID) + } + + fst, err := store.Stat(ctx, src.ID) + if err != nil { + t.Fatalf("Stat(folder): %v", err) + } + if fst.ID != src.ID || fst.Kind != Folder { + t.Fatalf("Stat(folder) = %+v", fst) + } + + flist, err := store.List(ctx, src.ID) + if err != nil { + t.Fatalf("List(folder): %v", err) + } + if entryByID(flist, child.ID) == nil { + t.Fatalf("child %s not in List(src)", child.ID) + } + + folderTitle := "renamed-folder-" + suffix + renameEventually(t, ctx, store, src.ID, folderTitle) + if e, err := store.Stat(ctx, src.ID); err != nil { + t.Fatalf("Stat(folder) after rename: %v", err) + } else if e.Kind != Folder || e.Title != folderTitle { + t.Fatalf("folder after rename = %+v, want title %q", e, folderTitle) + } + + moveEventually(t, ctx, store, src.ID, dst.ID) + if !waitEntry(ctx, store, dst.ID, src.ID, 20*time.Second) { + t.Fatalf("moved folder %s not in dst %s", src.ID, dst.ID) + } + if !waitNoEntry(ctx, store, root, src.ID, 20*time.Second) { + t.Fatalf("folder %s still in root after move", src.ID) + } + if !waitEntry(ctx, store, src.ID, child.ID, 20*time.Second) { + t.Fatalf("child file %s lost after moving folder %s", child.ID, src.ID) + } + + if err := store.Delete(ctx, []string{child.ID}); err != nil { + t.Fatalf("Delete(child): %v", err) + } + if err := store.Delete(ctx, []string{src.ID}); err != nil { + t.Fatalf("Delete(folder): %v", err) + } + if !waitNoEntry(ctx, store, dst.ID, src.ID, 20*time.Second) { + t.Fatalf("folder %s still present in dst after delete", src.ID) + } + if err := store.Delete(ctx, []string{dst.ID}); err != nil { + t.Fatalf("Delete(dst folder): %v", err) + } + if !waitNoEntry(ctx, store, root, dst.ID, 20*time.Second) { + t.Fatalf("dst folder %s still present in root after delete", dst.ID) + } } // moveEventually issues Move and retries while the operation is not visible yet @@ -141,6 +200,23 @@ func moveEventually(t *testing.T, ctx context.Context, store FileStore, id, dstI t.Fatalf("Move %s -> %s: %v", id, dstID, lastErr) } +// copyEventually issues Copy and retries while the new copy is not visible yet +// (copy is accepted asynchronously, like move). +func copyEventually(t *testing.T, ctx context.Context, store FileStore, id, dstID string, d time.Duration) *Entry { + t.Helper() + var lastErr error + for attempt := 0; attempt < 5; attempt++ { + if lastErr = store.Copy(ctx, []string{id}, dstID); lastErr == nil { + if e := waitOtherFile(ctx, store, dstID, id, d); e != nil { + return e + } + } + time.Sleep(time.Second) + } + t.Fatalf("Copy %s -> %s: %v", id, dstID, lastErr) + return nil +} + func renameEventually(t *testing.T, ctx context.Context, store FileStore, id, title string) { t.Helper() var lastErr error