mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
History could restore an edit or a deletion, but a file a run CREATED was the one thing it couldn't reverse — the ADDED row said so in copy and offered no button. The missing capability was a hub-written delete op: restore.go only ever journaled puts. POST /api/p/<id>/remove journals exactly one journal.KindDelete op under the hub's own device identity, behind restore's gates (gateUpload, PermWrite, cleanUploadPath, quota CheckWrite/RecordUsage) plus a volume- snapshot existence check so the API 404s on what the tree doesn't show. Commit's journal-append tail moves into RemoteSource.appendOp, which both writes now share — one writer per journal, unchanged. The ADDED-in-a-run row gets an "undo — remove file" control that confirms first (it reaches every synced device), and the DELETED row it leaves behind restores the file with its original bytes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
69 lines
1.9 KiB
Go
69 lines
1.9 KiB
Go
package syncer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
)
|
|
|
|
// The whole point of BEA-35: a file an agent run created can be un-created
|
|
// from the hub's History view, and the removal reaches every device like any
|
|
// other change. The hub writes ONE delete op into its OWN journal — the
|
|
// device's log is untouched, so one-writer-per-journal survives.
|
|
func TestHubRemoveReachesDevices(t *testing.T) {
|
|
storage := sharedRemote(t)
|
|
ts, p := newHub(t, storage, true)
|
|
|
|
viaServer, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer viaServer.Close()
|
|
a := newDevice(t, "deva", viaServer)
|
|
b := newDevice(t, "devb", remote.Prefixed(storage, p.ID))
|
|
|
|
write(t, a.Folder, "ideas.md", "an agent dumped this")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
if read(t, b.Folder, "ideas.md") != "an agent dumped this" {
|
|
t.Fatal("b never received the file")
|
|
}
|
|
ownBefore := journalBytes(t, a, "deva")
|
|
|
|
post(t, ts.URL+"/api/p/"+p.ID+"/remove", `{"path":"ideas.md"}`)
|
|
|
|
res := cycle(t, a)
|
|
if res.PulledOps != 1 {
|
|
t.Fatalf("a pulled %d ops, want the hub's one delete", res.PulledOps)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(a.Folder, "ideas.md")); !os.IsNotExist(err) {
|
|
t.Fatal("the hub's removal did not unlink the file on a")
|
|
}
|
|
if !bytes.Equal(journalBytes(t, a, "deva"), ownBefore) {
|
|
t.Fatal("the hub's removal rewrote the device's own journal")
|
|
}
|
|
// and onward to a device that talks to storage directly
|
|
cycle(t, b)
|
|
if _, err := os.Stat(filepath.Join(b.Folder, "ideas.md")); !os.IsNotExist(err) {
|
|
t.Fatal("the removal did not reach the direct-to-storage device")
|
|
}
|
|
}
|
|
|
|
func post(t *testing.T, url, body string) {
|
|
t.Helper()
|
|
res, err := http.Post(url, "application/json", strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != 200 {
|
|
t.Fatalf("POST %s: %d", url, res.StatusCode)
|
|
}
|
|
}
|