From 94951fc6c5eef25cd257d039ad7cb3c5aee869d6 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 16 Sep 2026 16:30:07 +0000 Subject: [PATCH] feat(files): canonical Entry + FileStore REST/DAV adapters (#35) --- file_core.go | 199 ++++++++++++++++++++++++++++ file_core_test.go | 193 +++++++++++++++++++++++++++ file_dav.go | 189 +++++++++++++++++++++++++++ file_rest.go | 230 +++++++++++++++++++++++++++++++++ file_store_integration_test.go | 209 ++++++++++++++++++++++++++++++ files.go | 23 ++-- 6 files changed, 1033 insertions(+), 10 deletions(-) create mode 100644 file_core.go create mode 100644 file_core_test.go create mode 100644 file_dav.go create mode 100644 file_rest.go create mode 100644 file_store_integration_test.go diff --git a/file_core.go b/file_core.go new file mode 100644 index 0000000..b9766ca --- /dev/null +++ b/file_core.go @@ -0,0 +1,199 @@ +package onlyoffice + +// Canonical file model and the backend-agnostic store interface. REST +// (files.go), WebDAV (files_webdav.go) and future backends (PostgreSQL, +// Elasticsearch) implement FileStore/Searcher so callers stop depending on a +// concrete transport. This file holds only types and pure conversions — no IO. + +import ( + "context" + "io" + "mime" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Kind distinguishes files from folders in the canonical model. +type Kind int + +const ( + File Kind = iota + Folder +) + +// String renders the kind for logs and table output. +func (k Kind) String() string { + switch k { + case File: + return "file" + case Folder: + return "folder" + default: + return "unknown" + } +} + +// Provider names for the FileStore adapters. +const ( + ProviderREST = "rest" + ProviderDAV = "dav" +) + +// Entry is the backend-independent representation of a document or folder. +// Fields that a backend cannot supply stay at their zero value. +type Entry struct { + ID string + ParentID string + Title string + Kind Kind + Size int64 + MIME string + Created time.Time + Modified time.Time + Version int + Provider string +} + +// FileStore is the operation surface every file backend implements. +type FileStore interface { + Name() string + List(ctx context.Context, parentID string) ([]Entry, error) + Stat(ctx context.Context, id string) (Entry, error) + CreateFolder(ctx context.Context, parentID, title string) (Entry, error) + Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error) + Download(ctx context.Context, id string, w io.Writer) (int64, error) + Move(ctx context.Context, ids []string, parentID string) error + Copy(ctx context.Context, ids []string, parentID string) error + Rename(ctx context.Context, id, title string) error + Delete(ctx context.Context, ids []string) error +} + +// SearchQuery narrows a Searcher request. InContent asks the backend to match +// document bodies, not just titles. +type SearchQuery struct { + Text string + InContent bool + FolderID string + Extensions []string + Limit int +} + +// SearchHit is one Searcher result: the matching entry plus backend-specific +// ranking metadata. +type SearchHit struct { + Entry + Score float64 + Highlight string + Path []string +} + +// Searcher is the optional content/name search surface. Only some backends +// (for example Elasticsearch) provide it. +type Searcher interface { + Search(ctx context.Context, q SearchQuery) ([]SearchHit, error) + Name() string +} + +// FileStore returns the adapter for a backend name: ProviderREST (default) or +// ProviderDAV. Unknown or empty names select the REST backend. The full facade +// (backend composition) is deliberately left to a later change. +func (c *Client) FileStore(backend string) FileStore { + switch strings.ToLower(strings.TrimSpace(backend)) { + case ProviderDAV, "webdav": + return &davStore{c: c} + default: + return &restStore{c: c} + } +} + +// Files returns the default (REST) file store. +func (c *Client) Files() FileStore { return c.FileStore(ProviderREST) } + +// retryStoreOp runs one store operation under the shared deterministic +// transient-error policy (429/502/503/504). +func retryStoreOp(ctx context.Context, fn func() error) error { + return DoRetry(ctx, DefaultRetryPolicy(), fn) +} + +// FileEntryToEntry converts a Files-module file row to the canonical model. +func FileEntryToEntry(f *FileEntry, provider string) Entry { + e := Entry{Kind: File, Provider: provider} + if f == nil { + return e + } + if f.ID != nil { + e.ID = f.ID.String() + } + e.ParentID = FileFolderID(f) + if f.Title != nil { + e.Title = *f.Title + } + if f.ContentLength != nil { + e.Size = parseContentLength(*f.ContentLength) + } + exst := "" + if f.FileExst != nil { + exst = *f.FileExst + } + e.MIME = mimeForTitle(e.Title, exst) + if f.Updated != nil { + e.Modified = *f.Updated + } + return e +} + +// DavFileToEntry converts a WebDAV file row to the canonical model. +func DavFileToEntry(f DavFile, provider string) Entry { + return Entry{ + ID: f.ID, + Title: f.Title, + Kind: File, + Size: f.Size, + MIME: mimeForTitle(f.Title, ""), + Modified: f.ModTime(), + Provider: provider, + } +} + +// DavFolderToEntry converts a WebDAV folder row to the canonical model. +func DavFolderToEntry(f DavFolder, provider string) Entry { + return Entry{ + ID: f.ID, + ParentID: f.ParentID, + Title: f.Title, + Kind: Folder, + Modified: f.ModTime(), + Provider: provider, + } +} + +// parseContentLength reads the leading integer of an OnlyOffice contentLength +// string (the API sometimes appends a unit, e.g. "12345 b"). +func parseContentLength(s string) int64 { + fields := strings.Fields(s) + if len(fields) == 0 { + return 0 + } + n, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + return 0 + } + return n +} + +// mimeForTitle derives a MIME type from an explicit extension or the title. +func mimeForTitle(title, exst string) string { + ext := strings.TrimSpace(exst) + if ext == "" { + ext = filepath.Ext(title) + } + if ext == "" { + return "" + } + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + return mime.TypeByExtension(strings.ToLower(ext)) +} diff --git a/file_core_test.go b/file_core_test.go new file mode 100644 index 0000000..91aa6c2 --- /dev/null +++ b/file_core_test.go @@ -0,0 +1,193 @@ +package onlyoffice + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestFileEntryToEntry(t *testing.T) { + id := json.Number("42") + title := "invoice.pdf" + exst := ".pdf" + size := "12345" + parent := json.Number("7") + updated := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + f := &FileEntry{ + ID: &id, + Title: &title, + FileExst: &exst, + ContentLength: &size, + FolderID: &parent, + Updated: &updated, + } + + e := FileEntryToEntry(f, ProviderREST) + if e.ID != "42" { + t.Errorf("ID = %q, want 42", e.ID) + } + if e.ParentID != "7" { + t.Errorf("ParentID = %q, want 7", e.ParentID) + } + if e.Title != title { + t.Errorf("Title = %q, want %q", e.Title, title) + } + if e.Kind != File { + t.Errorf("Kind = %v, want file", e.Kind) + } + if e.Size != 12345 { + t.Errorf("Size = %d, want 12345", e.Size) + } + if e.MIME != "application/pdf" { + t.Errorf("MIME = %q, want application/pdf", e.MIME) + } + if !e.Modified.Equal(updated) { + t.Errorf("Modified = %v, want %v", e.Modified, updated) + } + if e.Provider != ProviderREST { + t.Errorf("Provider = %q, want %q", e.Provider, ProviderREST) + } +} + +func TestFileEntryToEntryNil(t *testing.T) { + e := FileEntryToEntry(nil, ProviderDAV) + if e.Kind != File { + t.Errorf("Kind = %v, want file", e.Kind) + } + if e.ID != "" || e.Title != "" { + t.Errorf("nil entry should be empty: %+v", e) + } + if e.Provider != ProviderDAV { + t.Errorf("Provider = %q, want %q", e.Provider, ProviderDAV) + } +} + +func TestFileEntryToEntrySizeFormats(t *testing.T) { + cases := map[string]int64{ + "12345": 12345, + "12345 b": 12345, + "0": 0, + "": 0, + "notanum": 0, + } + for in, want := range cases { + got := parseContentLength(in) + if got != want { + t.Errorf("parseContentLength(%q) = %d, want %d", in, got, want) + } + } +} + +func TestDavFileToEntry(t *testing.T) { + f := DavFile{ + ID: "9", + Title: "note.txt", + Size: 10, + Updated: "2026-01-02T03:04:05.0000000+01:00", + } + e := DavFileToEntry(f, ProviderDAV) + if e.ID != "9" || e.Title != "note.txt" { + t.Errorf("identity mismatch: %+v", e) + } + if e.Kind != File { + t.Errorf("Kind = %v, want file", e.Kind) + } + if e.Size != 10 { + t.Errorf("Size = %d, want 10", e.Size) + } + if !strings.HasPrefix(e.MIME, "text/plain") { + t.Errorf("MIME = %q, want text/plain*", e.MIME) + } + if e.Modified.IsZero() { + t.Error("Modified not parsed") + } + if e.Provider != ProviderDAV { + t.Errorf("Provider = %q, want %q", e.Provider, ProviderDAV) + } +} + +func TestDavFolderToEntry(t *testing.T) { + f := DavFolder{ + ID: "5", + Title: "inbox", + ParentID: "1", + Updated: "2026-01-02T03:04:05.0000000+01:00", + } + e := DavFolderToEntry(f, ProviderDAV) + if e.ID != "5" || e.Title != "inbox" || e.ParentID != "1" { + t.Errorf("identity mismatch: %+v", e) + } + if e.Kind != Folder { + t.Errorf("Kind = %v, want folder", e.Kind) + } + if e.MIME != "" { + t.Errorf("folder MIME = %q, want empty", e.MIME) + } + if e.Modified.IsZero() { + t.Error("Modified not parsed") + } +} + +func TestEntriesFromFolderMap(t *testing.T) { + m := map[string]any{ + "files": []any{ + map[string]any{"id": float64(42), "title": "a.pdf", "pureContentLength": float64(7)}, + }, + "folders": []any{ + map[string]any{"id": float64(7), "title": "sub", "parentId": float64(1)}, + }, + } + entries, err := entriesFromFolderMap(m, ProviderREST) + if err != nil { + t.Fatalf("entriesFromFolderMap: %v", err) + } + if len(entries) != 2 { + t.Fatalf("got %d entries, want 2: %+v", len(entries), entries) + } + byID := map[string]Entry{} + for _, e := range entries { + byID[e.ID] = e + } + if got := byID["42"]; got.Kind != File || got.Size != 7 || got.Title != "a.pdf" { + t.Errorf("file entry = %+v", got) + } + if got := byID["7"]; got.Kind != Folder || got.ParentID != "1" || got.Title != "sub" { + t.Errorf("folder entry = %+v", got) + } +} + +func TestEntriesFromFolderMapNil(t *testing.T) { + entries, err := entriesFromFolderMap(nil, ProviderREST) + if err != nil || entries != nil { + t.Fatalf("got %v, %v; want nil, nil", entries, err) + } +} + +func TestKindString(t *testing.T) { + if File.String() != "file" || Folder.String() != "folder" { + t.Errorf("kind strings: %q %q", File.String(), Folder.String()) + } + if Kind(9).String() != "unknown" { + t.Errorf("unknown kind = %q", Kind(9).String()) + } +} + +func TestClientFileStoreSelection(t *testing.T) { + c := NewClient(Credentials{}) + if got := c.FileStore(ProviderDAV).Name(); got != ProviderDAV { + t.Errorf("FileStore(dav).Name() = %q", got) + } + if got := c.FileStore("webdav").Name(); got != ProviderDAV { + t.Errorf("FileStore(webdav).Name() = %q", got) + } + if got := c.FileStore(ProviderREST).Name(); got != ProviderREST { + t.Errorf("FileStore(rest).Name() = %q", got) + } + if got := c.FileStore("").Name(); got != ProviderREST { + t.Errorf("FileStore(\"\").Name() = %q", got) + } + if got := c.Files().Name(); got != ProviderREST { + t.Errorf("Files().Name() = %q", got) + } +} diff --git a/file_dav.go b/file_dav.go new file mode 100644 index 0000000..9183831 --- /dev/null +++ b/file_dav.go @@ -0,0 +1,189 @@ +package onlyoffice + +// davStore implements FileStore on top of the Documents/WebDAV methods in +// files_webdav.go. The Documents fileops calls need folder and file ids +// separated, so ids are classified through Stat before move/copy/rename/delete. + +import ( + "bytes" + "context" + "fmt" + "io" +) + +// davStore is a FileStore over the WebDAV-oriented Documents API. +type davStore struct{ c *Client } + +// Name reports the backend name. +func (s *davStore) Name() string { return ProviderDAV } + +// List returns the files and folders directly below parentID. +func (s *davStore) List(ctx context.Context, parentID string) ([]Entry, error) { + var out []Entry + err := retryStoreOp(ctx, func() error { + l, err := s.c.ListDavFolder(ctx, parentID) + if err != nil { + return err + } + entries := make([]Entry, 0, len(l.Folders)+len(l.Files)) + for _, f := range l.Folders { + entries = append(entries, DavFolderToEntry(f, ProviderDAV)) + } + for _, f := range l.Files { + entries = append(entries, DavFileToEntry(f, ProviderDAV)) + } + out = entries + return nil + }) + return out, err +} + +// Stat resolves a folder or file entry by id. A folder answers ListDavFolder +// with its own metadata in Current; otherwise the file metadata API is used. +func (s *davStore) Stat(ctx context.Context, id string) (Entry, error) { + return s.stat(ctx, id) +} + +// CreateFolder creates a subfolder under parentID. +func (s *davStore) CreateFolder(ctx context.Context, parentID, title string) (Entry, error) { + var out Entry + err := retryStoreOp(ctx, func() error { + f, err := s.c.CreateDavFolder(ctx, parentID, title) + if err != nil { + return err + } + if f == nil { + return fmt.Errorf("onlyoffice: dav store: empty create-folder response") + } + out = DavFolderToEntry(*f, ProviderDAV) + return nil + }) + return out, err +} + +// Upload streams r into parentID as title. The reader is buffered once so a +// retry re-sends the same bytes instead of an exhausted stream. +func (s *davStore) Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error) { + data, err := io.ReadAll(r) + if err != nil { + return Entry{}, err + } + var out Entry + err = retryStoreOp(ctx, func() error { + f, err := s.c.UploadDavFile(ctx, parentID, title, bytes.NewReader(data)) + if err != nil { + return err + } + if f == nil { + return fmt.Errorf("onlyoffice: dav store: empty upload response") + } + out = DavFileToEntry(*f, ProviderDAV) + return nil + }) + return out, err +} + +// Download streams the file bytes into w. +func (s *davStore) Download(ctx context.Context, id string, w io.Writer) (int64, error) { + var n int64 + err := retryStoreOp(ctx, func() error { + var e error + n, e = s.c.DownloadDavFile(ctx, id, w) + return e + }) + return n, err +} + +// Move moves ids into parentID, splitting folders from files. +func (s *davStore) Move(ctx context.Context, ids []string, parentID string) error { + folders, files, err := s.split(ctx, ids) + if err != nil { + return err + } + if len(folders) == 0 && len(files) == 0 { + return nil + } + return retryStoreOp(ctx, func() error { + return s.c.MoveDavItems(ctx, folders, files, parentID) + }) +} + +// Copy copies ids into parentID, splitting folders from files. +func (s *davStore) Copy(ctx context.Context, ids []string, parentID string) error { + folders, files, err := s.split(ctx, ids) + if err != nil { + return err + } + if len(folders) == 0 && len(files) == 0 { + return nil + } + return retryStoreOp(ctx, func() error { + return s.c.CopyDavItems(ctx, folders, files, parentID) + }) +} + +// Rename renames a folder or file. +func (s *davStore) Rename(ctx context.Context, id, title string) error { + e, err := s.stat(ctx, id) + if err != nil { + return err + } + return retryStoreOp(ctx, func() error { + if e.Kind == Folder { + return s.c.RenameDavFolder(ctx, id, title) + } + return s.c.RenameDavFile(ctx, id, title) + }) +} + +// Delete removes ids, splitting folders from files. +func (s *davStore) Delete(ctx context.Context, ids []string) error { + folders, files, err := s.split(ctx, ids) + if err != nil { + return err + } + if len(folders) == 0 && len(files) == 0 { + return nil + } + return retryStoreOp(ctx, func() error { + return s.c.DeleteDavItems(ctx, folders, files) + }) +} + +// stat resolves a single id to a folder or file Entry. +func (s *davStore) stat(ctx context.Context, id string) (Entry, error) { + var out Entry + err := retryStoreOp(ctx, func() error { + if l, err := s.c.ListDavFolder(ctx, id); err == nil { + if l != nil && l.Current.ID != "" && l.Current.ID == id { + out = DavFolderToEntry(l.Current, ProviderDAV) + return nil + } + } else if Transient(err) { + return err + } + f, err := s.c.GetFile(ctx, id) + if err != nil { + return err + } + out = FileEntryToEntry(f, ProviderDAV) + return nil + }) + return out, err +} + +// split classifies ids into folder and file id lists. +func (s *davStore) split(ctx context.Context, ids []string) (folders, files []string, err error) { + for _, id := range ids { + e, err := s.stat(ctx, id) + if err != nil { + return nil, nil, err + } + if e.Kind == Folder { + folders = append(folders, id) + } else { + files = append(files, id) + } + } + return folders, files, nil +} diff --git a/file_rest.go b/file_rest.go new file mode 100644 index 0000000..ed236a2 --- /dev/null +++ b/file_rest.go @@ -0,0 +1,230 @@ +package onlyoffice + +// restStore implements FileStore on top of the REST Documents methods in +// files.go. It is a thin adapter: no endpoint logic lives here, and every call +// is wrapped in DoRetry. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +// restStore is a FileStore over the REST Documents API. +type restStore struct{ c *Client } + +// Name reports the backend name. +func (s *restStore) Name() string { return ProviderREST } + +// List returns the files and folders directly below parentID. +func (s *restStore) List(ctx context.Context, parentID string) ([]Entry, error) { + var out []Entry + err := retryStoreOp(ctx, func() error { + raw, err := s.c.ListFolder(ctx, parentID) + if err != nil { + return err + } + entries, err := entriesFromFolderMap(raw, ProviderREST) + if err != nil { + return err + } + out = entries + return nil + }) + return out, err +} + +// Stat returns file metadata. The REST adapter resolves files only; folders +// are listed by their parent (use List). +func (s *restStore) Stat(ctx context.Context, id string) (Entry, error) { + var out Entry + err := retryStoreOp(ctx, func() error { + f, err := s.c.GetFile(ctx, id) + if err != nil { + return err + } + out = FileEntryToEntry(f, ProviderREST) + return nil + }) + return out, err +} + +// CreateFolder creates a subfolder under parentID. +func (s *restStore) CreateFolder(ctx context.Context, parentID, title string) (Entry, error) { + var out Entry + err := retryStoreOp(ctx, func() error { + m, err := s.c.CreateFolder(ctx, parentID, title) + if err != nil { + return err + } + e, err := folderEntryFromMap(m, parentID, ProviderREST) + if err != nil { + return err + } + if e.ParentID == "" { + e.ParentID = parentID + } + if e.Title == "" { + e.Title = title + } + out = e + return nil + }) + return out, err +} + +// Upload streams r into parentID as title. UploadToFolder is path based, so +// the reader is spooled to a temporary file first (ponytail: OnlyOffice +// multipart upload buffers the whole body anyway). +func (s *restStore) Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error) { + dir, err := os.MkdirTemp("", "oo-rest-upload-") + if err != nil { + return Entry{}, err + } + defer os.RemoveAll(dir) + + local := filepath.Join(dir, SafeLocalFileName(title)) + f, err := os.Create(local) + if err != nil { + return Entry{}, err + } + if _, err := io.Copy(f, r); err != nil { + f.Close() + return Entry{}, err + } + if err := f.Close(); err != nil { + return Entry{}, err + } + + var out Entry + err = retryStoreOp(ctx, func() error { + fe, err := s.c.UploadToFolder(ctx, parentID, local) + if err != nil { + return err + } + out = FileEntryToEntry(fe, ProviderREST) + return nil + }) + return out, err +} + +// Download streams the file bytes into w. +func (s *restStore) Download(ctx context.Context, id string, w io.Writer) (int64, error) { + var n int64 + err := retryStoreOp(ctx, func() error { + var e error + n, e = s.c.DownloadFile(ctx, id, w) + return e + }) + return n, err +} + +// Move moves file ids into parentID. The REST MoveFiles endpoint handles files +// only; folder moves are not exposed by this adapter. +func (s *restStore) Move(ctx context.Context, ids []string, parentID string) error { + dest, err := strconv.Atoi(strings.TrimSpace(parentID)) + if err != nil { + return fmt.Errorf("onlyoffice: rest store: move: non-numeric destination folder id %q", parentID) + } + fileIDs, err := numericIDs(ids) + if err != nil { + return err + } + return retryStoreOp(ctx, func() error { + _, err := s.c.MoveFiles(ctx, dest, fileIDs) + return err + }) +} + +// Copy copies file ids into parentID. files.go has no copy method, so the +// shared REST fileops copy endpoint (CopyDavItems) is used. +func (s *restStore) Copy(ctx context.Context, ids []string, parentID string) error { + if len(ids) == 0 { + return nil + } + return retryStoreOp(ctx, func() error { + return s.c.CopyDavItems(ctx, nil, ids, parentID) + }) +} + +// Rename sets a new title (including extension) for a file. +func (s *restStore) Rename(ctx context.Context, id, title string) error { + return retryStoreOp(ctx, func() error { + _, err := s.c.RenameFile(ctx, id, title) + return err + }) +} + +// Delete permanently deletes file ids. +func (s *restStore) Delete(ctx context.Context, ids []string) error { + fileIDs, err := numericIDs(ids) + if err != nil { + return err + } + if len(fileIDs) == 0 { + return nil + } + return retryStoreOp(ctx, func() error { + return s.c.DeleteFiles(ctx, fileIDs) + }) +} + +// entriesFromFolderMap converts a ListFolder response map into canonical +// entries, reusing the DavFile/DavFolder decoders for robust size handling. +func entriesFromFolderMap(m map[string]any, provider string) ([]Entry, error) { + if m == nil { + return nil, nil + } + b, err := json.Marshal(m) + if err != nil { + return nil, err + } + var listing DavListing + if err := json.Unmarshal(b, &listing); err != nil { + return nil, err + } + out := make([]Entry, 0, len(listing.Folders)+len(listing.Files)) + for _, f := range listing.Folders { + out = append(out, DavFolderToEntry(f, provider)) + } + for _, f := range listing.Files { + out = append(out, DavFileToEntry(f, provider)) + } + return out, nil +} + +// folderEntryFromMap converts a CreateFolder response map into a folder Entry. +func folderEntryFromMap(m map[string]any, parentID, provider string) (Entry, error) { + e := Entry{Kind: Folder, Provider: provider, ParentID: parentID} + if m == nil { + return e, nil + } + b, err := json.Marshal(m) + if err != nil { + return e, err + } + var f DavFolder + if err := json.Unmarshal(b, &f); err != nil { + return e, err + } + e = DavFolderToEntry(f, provider) + return e, nil +} + +// numericIDs parses Documents numeric ids from strings. +func numericIDs(ids []string) ([]int, error) { + out := make([]int, 0, len(ids)) + for _, id := range ids { + n, err := strconv.Atoi(strings.TrimSpace(id)) + if err != nil { + return nil, fmt.Errorf("onlyoffice: rest store: non-numeric id %q", id) + } + out = append(out, n) + } + return out, nil +} diff --git a/file_store_integration_test.go b/file_store_integration_test.go new file mode 100644 index 0000000..4533cf8 --- /dev/null +++ b/file_store_integration_test.go @@ -0,0 +1,209 @@ +//go:build integration + +package onlyoffice + +import ( + "bytes" + "context" + "strconv" + "testing" + "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. +// +// The Documents fileops API is asynchronous: a move/copy/delete is accepted +// immediately and becomes visible a moment later, so effects are polled. +func TestIntegrationFileStores(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 + "store-" + suffix, + Description: "go-onlyoffice file store 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) + } + + for _, backend := range []string{ProviderREST, ProviderDAV} { + t.Run(backend, func(t *testing.T) { + testFileStoreOps(t, ctx, c, c.FileStore(backend), root, suffix) + }) + } +} + +func testFileStoreOps(t *testing.T, ctx context.Context, c *Client, store FileStore, root, suffix string) { + t.Helper() + content := []byte("file store " + store.Name() + " " + suffix + "\n") + + src, err := store.CreateFolder(ctx, root, "fs-src-"+suffix) + if err != nil { + t.Fatalf("CreateFolder src: %v", err) + } + if src.Kind != Folder || src.ID == "" { + t.Fatalf("created src folder: %+v", src) + } + dst, err := store.CreateFolder(ctx, root, "fs-dst-"+suffix) + if err != nil { + t.Fatalf("CreateFolder dst: %v", err) + } + if dst.Kind != Folder || dst.ID == "" { + t.Fatalf("created dst folder: %+v", dst) + } + t.Cleanup(func() { + if err := c.DeleteDavItems(ctx, []string{src.ID, dst.ID}, nil); err != nil { + t.Logf("cleanup folders: %v", err) + } + }) + + up, err := store.Upload(ctx, src.ID, "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 !waitEntry(ctx, store, src.ID, up.ID, 15*time.Second) { + t.Fatalf("uploaded %s not listed in src", up.ID) + } + + st, err := store.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) + } + + var buf bytes.Buffer + n, err := store.Download(ctx, up.ID, &buf) + if err != nil { + t.Fatalf("Download: %v", err) + } + if n != int64(len(content)) || !bytes.Equal(buf.Bytes(), content) { + t.Fatalf("download mismatch: got %d bytes %q want %d", n, buf.String(), len(content)) + } + + moveEventually(t, ctx, store, up.ID, dst.ID) + if !waitEntry(ctx, store, dst.ID, up.ID, 20*time.Second) { + 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) + if copied == nil { + t.Fatalf("no copy found in src after Copy") + } + + newTitle := "renamed-" + suffix + ".txt" + renameEventually(t, ctx, store, up.ID, newTitle) + + if err := store.Delete(ctx, []string{up.ID, copied.ID}); err != nil { + t.Fatalf("Delete: %v", err) + } + if !waitNoEntry(ctx, store, dst.ID, up.ID, 20*time.Second) { + t.Fatalf("file %s still present in dst after delete", up.ID) + } + if !waitNoEntry(ctx, store, src.ID, copied.ID, 20*time.Second) { + t.Fatalf("copy %s still present in src after delete", copied.ID) + } +} + +// moveEventually issues Move and retries while the operation is not visible yet +// (the fileops API accepts asynchronously and occasionally rejects a move that +// raced the just-finished upload). +func moveEventually(t *testing.T, ctx context.Context, store FileStore, id, dstID string) { + t.Helper() + var lastErr error + for attempt := 0; attempt < 5; attempt++ { + if lastErr = store.Move(ctx, []string{id}, dstID); lastErr == nil { + if waitEntry(ctx, store, dstID, id, 6*time.Second) { + return + } + } + time.Sleep(time.Second) + } + t.Fatalf("Move %s -> %s: %v", id, dstID, lastErr) +} + +func renameEventually(t *testing.T, ctx context.Context, store FileStore, id, title string) { + t.Helper() + var lastErr error + for attempt := 0; attempt < 5; attempt++ { + if lastErr = store.Rename(ctx, id, title); lastErr == nil { + if e, err := store.Stat(ctx, id); err == nil && e.Title == title { + return + } + } + time.Sleep(time.Second) + } + t.Fatalf("Rename %s -> %q: %v", id, title, lastErr) +} + +func waitEntry(ctx context.Context, store FileStore, parentID, id string, d time.Duration) bool { + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if list, err := store.List(ctx, parentID); err == nil && entryByID(list, id) != nil { + return true + } + time.Sleep(500 * time.Millisecond) + } + return false +} + +func waitNoEntry(ctx context.Context, store FileStore, parentID, id string, d time.Duration) bool { + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if list, err := store.List(ctx, parentID); err == nil && entryByID(list, id) == nil { + return true + } + time.Sleep(500 * time.Millisecond) + } + return false +} + +func waitOtherFile(ctx context.Context, store FileStore, parentID, id string, d time.Duration) *Entry { + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if list, err := store.List(ctx, parentID); err == nil { + if e := firstFileOtherThan(list, id); e != nil { + return e + } + } + time.Sleep(500 * time.Millisecond) + } + return nil +} + +func entryByID(entries []Entry, id string) *Entry { + for i := range entries { + if entries[i].ID == id { + return &entries[i] + } + } + return nil +} + +func firstFileOtherThan(entries []Entry, id string) *Entry { + for i := range entries { + if entries[i].Kind == File && entries[i].ID != id { + return &entries[i] + } + } + return nil +} diff --git a/files.go b/files.go index 767d5d6..b3f38bc 100644 --- a/files.go +++ b/files.go @@ -324,19 +324,22 @@ func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int) "resolveType": "Skip", "holdResult": true, } - out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body) + // fileops/move answers an operations envelope (like MoveDavItems), not a + // single object, so parse the raw body before unwrapping and surface any + // per-operation error. Unwrapping first (putJSONObject) made fileopsError + // look for a "response" key that was already stripped. + raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", body) if err != nil { - out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body) - } - if err != nil { - return nil, err - } - if raw, merr := json.Marshal(out); merr == nil { - if ferr := fileopsError(raw); ferr != nil { - return nil, ferr + raw, err = c.putJSON(ctx, "/api/2.0/files/fileops/move.json", body) + if err != nil { + return nil, err } } - return out, err + if ferr := fileopsError(raw); ferr != nil { + return nil, ferr + } + out, _ := unmarshalResponseObject(raw) + return out, nil } // UploadToFolder uploads a local file into an arbitrary Documents folder id. -- 2.54.0