mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
There is no rename in beardrive: the scanner emits a put at the new path and a delete at the old, same device, same blob, one cycle. Everything keyed on a path therefore broke the moment a file moved — the viewer 404'd, history lost the file's own past versions, restore refused them, and a share link either 404'd or silently served whatever unrelated file later took its address. internal/webapp/moves.go derives the pairing from the ops the replay already walks, cached with the snapshot. Deliberately not a rename op: journal.Less and Replay are what every device converges to, and every already-shipped journal would still need the heuristic to read its own history. The two rules point in opposite directions on purpose. A viewer URL is an address, so a LIVE path always wins and only an empty one redirects. A share token is a promise about one file, so it follows the file even when a new one takes the old address — and 404s forever once the file is deleted. Nothing here writes an op or touches sync. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
379 lines
13 KiB
Go
379 lines
13 KiB
Go
package webapp
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
|
)
|
|
|
|
// t0 is the fixed clock these tests place ops on. Every pairing rule in
|
|
// moves.go is time-windowed, so "now" would make them unreadable.
|
|
var t0 = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
// delAt is the timed twin of fakeRemote.del (putAt already lives in
|
|
// history_test.go).
|
|
func (f *fakeRemote) delAt(dev, path string, at time.Time) {
|
|
f.t.Helper()
|
|
f.append(dev, journal.Op{Kind: journal.KindDelete, Path: path, Time: at})
|
|
}
|
|
|
|
// move is the shape the scanner actually emits: one device, one cycle, the
|
|
// same blob put at the new path and deleted at the old.
|
|
func (f *fakeRemote) move(dev, from, to, content string, at time.Time) {
|
|
f.t.Helper()
|
|
f.putAt(dev, to, content, at)
|
|
f.delAt(dev, from, at.Add(time.Second))
|
|
}
|
|
|
|
/* ---- the index ---- */
|
|
|
|
// buildIndex is the unit-level path: ops in, index out.
|
|
func buildIndex(ops ...journal.Op) moveIndex {
|
|
journal.Sort(ops)
|
|
return buildMoveIndex(ops)
|
|
}
|
|
|
|
func op(kind, path, blob string, at time.Time, dev string, lamport int64) journal.Op {
|
|
return journal.Op{
|
|
Kind: kind, Path: path, Blob: blob, Time: at, Device: dev,
|
|
Lamport: lamport, Seq: lamport, Size: 4, Mode: 0o644,
|
|
}
|
|
}
|
|
|
|
func sha(s string) string {
|
|
sum := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func TestMoveIndexPairsInEitherJournalOrder(t *testing.T) {
|
|
b := sha("body")
|
|
for _, tc := range []struct {
|
|
name string
|
|
ops []journal.Op
|
|
}{
|
|
{"put-then-delete", []journal.Op{
|
|
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
|
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute), "deva", 2),
|
|
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute+time.Second), "deva", 3),
|
|
}},
|
|
{"delete-then-put", []journal.Op{
|
|
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
|
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 2),
|
|
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute+4*time.Second), "deva", 3),
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
idx := buildIndex(tc.ops...)
|
|
evs := idx["a.md"]
|
|
if len(evs) != 1 || evs[0].To != "docs/a.md" {
|
|
t.Fatalf("a.md events = %+v, want one move to docs/a.md", evs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMoveIndexDeclinesOutsideWindow(t *testing.T) {
|
|
b := sha("body")
|
|
idx := buildIndex(
|
|
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
|
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 2),
|
|
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Hour), "deva", 3),
|
|
)
|
|
if evs := idx["a.md"]; len(evs) != 1 || evs[0].To != "" {
|
|
t.Fatalf("a.md events = %+v, want one plain deletion", evs)
|
|
}
|
|
}
|
|
|
|
func TestMoveIndexDeclinesAcrossDevices(t *testing.T) {
|
|
b := sha("body")
|
|
idx := buildIndex(
|
|
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
|
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 2),
|
|
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute+time.Second), "devb", 3),
|
|
)
|
|
if evs := idx["a.md"]; len(evs) != 1 || evs[0].To != "" {
|
|
t.Fatalf("a.md events = %+v, want one plain deletion", evs)
|
|
}
|
|
}
|
|
|
|
func TestMoveIndexAmbiguousIdenticalContent(t *testing.T) {
|
|
// Two files with the same bytes, one deleted, one created: content
|
|
// identity cannot say which is which, so nothing pairs.
|
|
b := sha("body")
|
|
idx := buildIndex(
|
|
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
|
op(journal.KindPut, "b.md", b, t0, "deva", 2),
|
|
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 3),
|
|
op(journal.KindDelete, "b.md", "", t0.Add(time.Minute), "deva", 4),
|
|
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute+time.Second), "deva", 5),
|
|
)
|
|
for _, p := range []string{"a.md", "b.md"} {
|
|
if evs := idx[p]; len(evs) != 1 || evs[0].To != "" {
|
|
t.Fatalf("%s events = %+v, want a plain deletion (ambiguous)", p, evs)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMoveIndexIgnoresOverwritingPut(t *testing.T) {
|
|
// docs/a.md already existed, so the put that happens to carry a.md's
|
|
// blob is an edit, not a move destination.
|
|
b := sha("body")
|
|
idx := buildIndex(
|
|
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
|
op(journal.KindPut, "docs/a.md", sha("other"), t0, "deva", 2),
|
|
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 3),
|
|
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute), "deva", 4),
|
|
)
|
|
if evs := idx["a.md"]; len(evs) != 1 || evs[0].To != "" {
|
|
t.Fatalf("a.md events = %+v, want a plain deletion", evs)
|
|
}
|
|
}
|
|
|
|
/* ---- resolvers ---- */
|
|
|
|
func TestResolveShareDeleteBeforeMove(t *testing.T) {
|
|
// a.md is deleted, an unrelated a.md is created at the same address,
|
|
// and THAT one moves away. A share minted before the delete is a promise
|
|
// about the first file — which is gone.
|
|
b, c := sha("first"), sha("second")
|
|
idx := buildIndex(
|
|
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
|
op(journal.KindDelete, "a.md", "", t0.Add(time.Hour), "deva", 2),
|
|
op(journal.KindPut, "a.md", c, t0.Add(2*time.Hour), "deva", 3),
|
|
op(journal.KindPut, "docs/a.md", c, t0.Add(3*time.Hour), "deva", 4),
|
|
op(journal.KindDelete, "a.md", "", t0.Add(3*time.Hour+time.Second), "deva", 5),
|
|
)
|
|
files := map[string]FileInfo{"docs/a.md": {Blob: c}}
|
|
if to, ok := resolveShare(idx, files, "a.md", t0); ok {
|
|
t.Fatalf("share minted before the delete resolved to %q, want gone", to)
|
|
}
|
|
to, ok := resolveShare(idx, files, "a.md", t0.Add(2*time.Hour+time.Minute))
|
|
if !ok || to != "docs/a.md" {
|
|
t.Fatalf("share minted after the recreate = %q %v, want docs/a.md", to, ok)
|
|
}
|
|
}
|
|
|
|
func TestResolveForwardCycle(t *testing.T) {
|
|
// A journal is peer JSON and can describe A→B→A. Answer "not found"
|
|
// rather than walk forever.
|
|
idx := moveIndex{
|
|
"a.md": {{At: t0, To: "b.md", ToAt: t0}},
|
|
"b.md": {{At: t0.Add(time.Minute), To: "a.md", ToAt: t0.Add(time.Minute)}},
|
|
}
|
|
if to, ok := resolveForward(idx, map[string]FileInfo{}, "a.md"); ok {
|
|
t.Fatalf("cycle resolved to %q, want not found", to)
|
|
}
|
|
if to, ok := resolveShare(idx, map[string]FileInfo{}, "a.md", time.Time{}); ok {
|
|
t.Fatalf("cycle resolved (share) to %q, want not found", to)
|
|
}
|
|
}
|
|
|
|
func TestChainSegmentsExcludeSuccessorAtOldPath(t *testing.T) {
|
|
b := sha("body")
|
|
idx := buildIndex(
|
|
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
|
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Hour), "deva", 2),
|
|
op(journal.KindDelete, "a.md", "", t0.Add(time.Hour+time.Second), "deva", 3),
|
|
op(journal.KindPut, "a.md", sha("unrelated"), t0.Add(2*time.Hour), "deva", 4),
|
|
)
|
|
segs := chainSegments(idx, "docs/a.md")
|
|
if !inSegments(segs, "a.md", t0) {
|
|
t.Error("the original a.md version is not in docs/a.md's chain")
|
|
}
|
|
if inSegments(segs, "a.md", t0.Add(2*time.Hour)) {
|
|
t.Error("the unrelated later a.md leaked into docs/a.md's chain")
|
|
}
|
|
if !inSegments(segs, "docs/a.md", t0.Add(time.Hour)) {
|
|
t.Error("docs/a.md's own creating op is not in its chain")
|
|
}
|
|
}
|
|
|
|
/* ---- viewer ---- */
|
|
|
|
func TestMovedFileRedirects(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
f.putAt("deva", "a.md", "# Body", t0)
|
|
f.move("deva", "a.md", "docs/a.md", "# Body", t0.Add(time.Hour))
|
|
h := f.server().Handler()
|
|
|
|
rec := get(t, h, "/api/file?path=a.md")
|
|
if rec.Code != 200 || rec.Body.String() != "# Body" {
|
|
t.Fatalf("moved file: %d %q", rec.Code, rec.Body)
|
|
}
|
|
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "docs/a.md" {
|
|
t.Fatalf("canonical header = %q, want docs/a.md", got)
|
|
}
|
|
// The destination itself carries no header — nothing moved.
|
|
rec = get(t, h, "/api/file?path=docs/a.md")
|
|
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "" {
|
|
t.Fatalf("canonical header on the live path = %q, want none", got)
|
|
}
|
|
// A path that never existed still 404s.
|
|
if rec := get(t, h, "/api/file?path=nope.md"); rec.Code != 404 {
|
|
t.Fatalf("unknown path: %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestLivePathWinsOverRedirect(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
f.putAt("deva", "a.md", "# Body", t0)
|
|
f.move("deva", "a.md", "docs/a.md", "# Body", t0.Add(time.Hour))
|
|
f.putAt("deva", "a.md", "# Brand new", t0.Add(2*time.Hour))
|
|
h := f.server().Handler()
|
|
|
|
rec := get(t, h, "/api/file?path=a.md")
|
|
if rec.Code != 200 || rec.Body.String() != "# Brand new" {
|
|
t.Fatalf("live path: %d %q, want the new file", rec.Code, rec.Body)
|
|
}
|
|
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "" {
|
|
t.Fatalf("canonical header = %q, want none (nothing moved out of a live path)", got)
|
|
}
|
|
}
|
|
|
|
func TestMoveChainResolvesInOneHop(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
f.putAt("deva", "a.md", "body", t0)
|
|
f.move("deva", "a.md", "b.md", "body", t0.Add(time.Hour))
|
|
f.move("deva", "b.md", "c.md", "body", t0.Add(2*time.Hour))
|
|
h := f.server().Handler()
|
|
|
|
rec := get(t, h, "/api/file?path=a.md")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("a.md: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "c.md" {
|
|
t.Fatalf("canonical header = %q, want c.md", got)
|
|
}
|
|
}
|
|
|
|
func TestRenderFollowsMove(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
f.putAt("deva", "a.md", "# Title", t0)
|
|
f.move("deva", "a.md", "docs/a.md", "# Title", t0.Add(time.Hour))
|
|
h := f.server().Handler()
|
|
|
|
rec := get(t, h, "/api/render?path=a.md")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("render: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var doc struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if doc.Path != "docs/a.md" {
|
|
t.Fatalf("rendered path = %q, want the canonical docs/a.md", doc.Path)
|
|
}
|
|
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "docs/a.md" {
|
|
t.Fatalf("canonical header = %q", got)
|
|
}
|
|
}
|
|
|
|
/* ---- /resolve ---- */
|
|
|
|
func TestResolveEndpoint(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
f.putAt("deva", "notes/one.md", "one", t0)
|
|
f.putAt("deva", "notes/two.md", "two", t0)
|
|
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
|
|
f.move("deva", "notes/two.md", "wiki/two.md", "two", t0.Add(time.Hour))
|
|
h := f.server().Handler()
|
|
|
|
var got struct{ To, Kind string }
|
|
rec := get(t, h, "/api/resolve?path=notes")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("folder resolve: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.To != "wiki" || got.Kind != "folder" {
|
|
t.Fatalf("folder resolve = %+v, want wiki/folder", got)
|
|
}
|
|
|
|
rec = get(t, h, "/api/resolve?path=notes/one.md")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("file resolve: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.To != "wiki/one.md" || got.Kind != "file" {
|
|
t.Fatalf("file resolve = %+v", got)
|
|
}
|
|
|
|
// A live path has not gone anywhere.
|
|
if rec := get(t, h, "/api/resolve?path=wiki/one.md"); rec.Code != 404 {
|
|
t.Fatalf("live path resolve: %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestResolveFolderIsAllOrNothing(t *testing.T) {
|
|
t.Run("half moved", func(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
f.putAt("deva", "notes/one.md", "one", t0)
|
|
f.putAt("deva", "notes/two.md", "two", t0)
|
|
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
|
|
if rec := get(t, f.server().Handler(), "/api/resolve?path=notes"); rec.Code != 404 {
|
|
t.Fatalf("partial move: %d, want 404 (notes/ still exists)", rec.Code)
|
|
}
|
|
})
|
|
t.Run("one deleted", func(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
f.putAt("deva", "notes/one.md", "one", t0)
|
|
f.putAt("deva", "notes/two.md", "two", t0)
|
|
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
|
|
f.delAt("deva", "notes/two.md", t0.Add(2*time.Hour))
|
|
if rec := get(t, f.server().Handler(), "/api/resolve?path=notes"); rec.Code != 404 {
|
|
t.Fatalf("deleted member: %d, want 404 (no honest destination)", rec.Code)
|
|
}
|
|
})
|
|
t.Run("split destinations", func(t *testing.T) {
|
|
f := newFakeRemote(t)
|
|
f.putAt("deva", "notes/one.md", "one", t0)
|
|
f.putAt("deva", "notes/two.md", "two", t0)
|
|
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
|
|
f.move("deva", "notes/two.md", "docs/two.md", "two", t0.Add(time.Hour))
|
|
if rec := get(t, f.server().Handler(), "/api/resolve?path=notes"); rec.Code != 404 {
|
|
t.Fatalf("split destinations: %d, want 404", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
/* ---- read heat lands on the canonical path ---- */
|
|
|
|
func TestRedirectedReadCreditsCanonicalPath(t *testing.T) {
|
|
srv, p, root := newHub(t, true, nil)
|
|
var err error
|
|
if srv.Reads, err = OpenReadLedger(filepath.Join(t.TempDir(), "reads.json"), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.putAt("deva", "a.md", "# Body", t0)
|
|
f.move("deva", "a.md", "docs/a.md", "# Body", t0.Add(time.Hour))
|
|
h := srv.Handler()
|
|
|
|
if rec := do(t, h, "GET", "/api/p/"+p.ID+"/file?path=a.md", nil); rec.Code != 200 {
|
|
t.Fatalf("read: %d %s", rec.Code, rec.Body)
|
|
}
|
|
rec := do(t, h, "GET", "/api/p/"+p.ID+"/heat?days=30", nil)
|
|
var heat struct {
|
|
Entries map[string]HeatEntry `json:"entries"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &heat); err != nil {
|
|
t.Fatalf("heat: %v (%s)", err, rec.Body)
|
|
}
|
|
if heat.Entries["docs/a.md"].Human == 0 {
|
|
t.Errorf("heat credited %v, want the read on docs/a.md", heat.Entries)
|
|
}
|
|
if heat.Entries["a.md"].Human != 0 {
|
|
t.Errorf("heat credited the OLD path a.md: %v", heat.Entries)
|
|
}
|
|
}
|