Files
beardrive/internal/syncer/syncer_test.go
Snow LeeandClaude Fable 5 10a9998672 feat(sync): fence nested mounts; knowledge-init flows in plugin docs
A subdirectory with its own .bdrive/config.json now syncs only through
its own project: the parent mount never scans into it, materializes
over it, or emits deletes for paths under it (cached paths are dropped
without delete ops, the same posture as newly ignored files). Detection
is presence-based (config.IsMount) so even a corrupt nested config
still fences. This makes the team-knowledge-folder-inside-a-personal-
mount topology safe.

The plugin's init command and skill gain a "connecting knowledge
tooling" playbook: a detection ladder (gbrain -> OKF wiki -> plain
docs folder -> starting-point menu) and two hard rules — one transport
per folder (git-tracked dirs are handed off via git rm --cached +
gitignore before syncing) and knowledge-as-scoped-subfolder (never a
repo root). Conflict copies documented with their exact glob
(*.bdrive-conflict-*), since openknowledge validate cannot see them.

New flow tests: shared-subfolder scope both ways, teammate connect
over identical/divergent local content, nested mount syncing
independently on two projects, and a guard pinning the conflict-copy
filename to the documented glob.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
2026-07-10 21:42:36 -07:00

385 lines
11 KiB
Go

package syncer
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
)
// TestPushProgress verifies the push phase reports upload progress: the total
// is the number of unique blobs, Done climbs to that total, and byte totals
// are populated. (Done isn't strictly ordered across the parallel workers, so
// we only assert it reaches the total.)
func TestPushProgress(t *testing.T) {
a := newDevice(t, "deva", sharedRemote(t))
const n = 25
for i := 0; i < n; i++ {
write(t, a.Folder, fmt.Sprintf("f%02d.txt", i), fmt.Sprintf("unique content for file %d — pad pad pad", i))
}
var mu sync.Mutex
var total, maxDone int
var toBytes int64
a.OnProgress = func(p Progress) {
mu.Lock()
defer mu.Unlock()
total = p.Total
toBytes = p.ToBytes
if p.Done > maxDone {
maxDone = p.Done
}
}
cycle(t, a)
if total != n {
t.Fatalf("progress Total = %d, want %d", total, n)
}
if maxDone != n {
t.Fatalf("progress reached Done = %d, want %d", maxDone, n)
}
if toBytes == 0 {
t.Fatal("progress ToBytes should be > 0")
}
}
// newDevice simulates one device: its own folder, volume store, and identity,
// all syncing through a shared file:// remote.
func newDevice(t *testing.T, name string, backend remote.Backend) *Session {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
if err != nil {
t.Fatal(err)
}
return &Session{
Folder: t.TempDir(),
Store: st,
Device: config.Device{ID: name, Name: name, Author: name + "@test"},
Backend: backend,
}
}
func sharedRemote(t *testing.T) remote.Backend {
t.Helper()
be, err := remote.Open(context.Background(), "file://"+t.TempDir())
if err != nil {
t.Fatal(err)
}
return be
}
func write(t *testing.T, folder, rel, content string) {
t.Helper()
abs := filepath.Join(folder, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func read(t *testing.T, folder, rel string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(folder, filepath.FromSlash(rel)))
if err != nil {
t.Fatalf("read %s: %v", rel, err)
}
return string(b)
}
func cycle(t *testing.T, s *Session) *Result {
t.Helper()
res, err := s.Cycle(context.Background())
if err != nil {
t.Fatal(err)
}
if res.Offline {
t.Fatalf("unexpected offline: %v", res.OfflineErr)
}
return res
}
func TestOfflineCycle(t *testing.T) {
a := newDevice(t, "deva", nil)
write(t, a.Folder, "notes/hello.md", "hi")
res := cycle(t, a)
if res.LocalOps != 1 {
t.Fatalf("LocalOps = %d, want 1", res.LocalOps)
}
// idempotent: second cycle sees no changes
res = cycle(t, a)
if res.Activity() {
t.Fatalf("second cycle should be quiet, got %+v", res)
}
}
func TestTwoDeviceSync(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
// A creates files, B receives them
write(t, a.Folder, "doc.txt", "v1")
write(t, a.Folder, "sub/nested.txt", "deep")
cycle(t, a)
res := cycle(t, b)
if res.PulledOps != 2 || res.Materialized != 2 {
t.Fatalf("b pull: %+v", res)
}
if read(t, b.Folder, "doc.txt") != "v1" || read(t, b.Folder, "sub/nested.txt") != "deep" {
t.Fatal("content mismatch after sync")
}
// B edits, A receives the update
time.Sleep(10 * time.Millisecond) // ensure mtime moves
write(t, b.Folder, "doc.txt", "v2 from b")
cycle(t, b)
cycle(t, a)
if got := read(t, a.Folder, "doc.txt"); got != "v2 from b" {
t.Fatalf("a got %q", got)
}
// B deletes, A's copy disappears
os.Remove(filepath.Join(b.Folder, "sub", "nested.txt"))
cycle(t, b)
cycle(t, a)
if _, err := os.Stat(filepath.Join(a.Folder, "sub", "nested.txt")); !os.IsNotExist(err) {
t.Fatal("delete did not propagate")
}
// empty dir pruned
if _, err := os.Stat(filepath.Join(a.Folder, "sub")); !os.IsNotExist(err) {
t.Fatal("empty dir not pruned")
}
}
func TestHistoryTracksDeviceAndAuthor(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
write(t, a.Folder, "f.txt", "from a")
cycle(t, a)
cycle(t, b)
time.Sleep(10 * time.Millisecond)
write(t, b.Folder, "f.txt", "from b")
cycle(t, b)
cycle(t, a)
entries, err := LogEntries(a.Store, "f.txt", 0)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("want 2 history entries, got %d: %+v", len(entries), entries)
}
// newest first
if entries[0].Author != "devb@test" || entries[0].DeviceName != "devb" {
t.Fatalf("newest entry should be devb's: %+v", entries[0])
}
if entries[1].Author != "deva@test" {
t.Fatalf("oldest entry should be deva's: %+v", entries[1])
}
}
func TestConcurrentEditConflictPreserved(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
// shared base
write(t, a.Folder, "shared.txt", "base")
cycle(t, a)
cycle(t, b)
// both edit before syncing
time.Sleep(10 * time.Millisecond)
write(t, a.Folder, "shared.txt", "edit from a")
write(t, b.Folder, "shared.txt", "edit from b")
cycle(t, a) // a pushes first
cycle(t, b) // b scans its edit, pulls a's, loses or wins deterministically
cycle(t, a) // a converges
cycle(t, b)
aContent := read(t, a.Folder, "shared.txt")
bContent := read(t, b.Folder, "shared.txt")
if aContent != bContent {
t.Fatalf("devices diverged: %q vs %q", aContent, bContent)
}
// both versions must survive somewhere (winner at path, loser as conflict copy)
all := map[string]bool{aContent: true}
for _, folder := range []string{a.Folder, b.Folder} {
entries, err := os.ReadDir(folder)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if strings.Contains(e.Name(), ".bdrive-conflict-") {
all[read(t, folder, e.Name())] = true
}
}
}
if !all["edit from a"] || !all["edit from b"] {
t.Fatalf("a version was lost; surviving: %v", all)
}
}
func TestMountExistingFolderImports(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
write(t, a.Folder, "pre-existing.txt", "I was here first")
res := cycle(t, a)
if res.LocalOps != 1 || !res.Pushed {
t.Fatalf("import failed: %+v", res)
}
b := newDevice(t, "devb", be)
cycle(t, b)
if read(t, b.Folder, "pre-existing.txt") != "I was here first" {
t.Fatal("existing file not imported/synced")
}
}
func TestIgnoredFiles(t *testing.T) {
a := newDevice(t, "deva", nil)
write(t, a.Folder, ".DS_Store", "junk")
write(t, a.Folder, ".git/config", "gitstuff")
write(t, a.Folder, "real.txt", "data")
res := cycle(t, a)
if res.LocalOps != 1 {
t.Fatalf("ignores leaked into journal: %+v", res)
}
}
func TestOfflineThenReconnect(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
// work offline
a.Backend = nil
write(t, a.Folder, "offline.txt", "written offline")
cycle(t, a)
// reconnect: pending ops push
a.Backend = be
res := cycle(t, a)
if !res.Pushed {
t.Fatalf("reconnect should push pending ops: %+v", res)
}
b := newDevice(t, "devb", be)
cycle(t, b)
if read(t, b.Folder, "offline.txt") != "written offline" {
t.Fatal("offline edit did not propagate after reconnect")
}
}
func TestSameVolumeMountedAtTwoFolders(t *testing.T) {
// One device mounts the same volume at two folders (e.g. ./shared in two
// repos). They share the store (blobs+journals) but have separate mount
// caches, and content propagates between them even with no remote.
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
if err != nil {
t.Fatal(err)
}
dev := config.Device{ID: "dev1", Name: "dev1", Author: "dev1@test"}
m1 := &Session{Folder: t.TempDir(), MountID: "mount1", Store: st, Device: dev}
m2 := &Session{Folder: t.TempDir(), MountID: "mount2", Store: st, Device: dev}
write(t, m1.Folder, "shared.md", "from folder one")
cycle(t, m1)
res := cycle(t, m2)
if res.Materialized != 1 {
t.Fatalf("folder two should materialize the file: %+v", res)
}
if read(t, m2.Folder, "shared.md") != "from folder one" {
t.Fatal("content did not propagate between mounts")
}
// edit in folder two propagates back
time.Sleep(10 * time.Millisecond)
write(t, m2.Folder, "shared.md", "edited in folder two")
cycle(t, m2)
cycle(t, m1)
if read(t, m1.Folder, "shared.md") != "edited in folder two" {
t.Fatal("edit did not propagate back to folder one")
}
}
func TestExecutableBitPreserved(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
abs := filepath.Join(a.Folder, "run.sh")
os.WriteFile(abs, []byte("#!/bin/sh\necho hi\n"), 0o755)
cycle(t, a)
b := newDevice(t, "devb", be)
cycle(t, b)
fi, err := os.Stat(filepath.Join(b.Folder, "run.sh"))
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm()&0o100 == 0 {
t.Fatalf("exec bit lost: %v", fi.Mode())
}
}
// TestNestedMountExcluded verifies that a subdirectory which is a BearDrive
// mount of its own (has .bdrive/config.json) is fenced off from the parent
// mount: the parent scanner never journals its files, dropping it emits no
// delete ops toward peers, and remote state is never materialized into it.
func TestNestedMountExcluded(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
// Both devices converge on a folder that includes team/.
write(t, a.Folder, "readme.md", "root")
write(t, a.Folder, "team/notes.md", "v1")
cycle(t, a)
cycle(t, b)
if got := read(t, b.Folder, "team/notes.md"); got != "v1" {
t.Fatalf("b team/notes.md = %q, want v1", got)
}
// team/ becomes a nested mount on A (its own project).
write(t, a.Folder, "team/.bdrive/config.json", `{"mount_id":"m-nested"}`)
write(t, a.Folder, "team/local.md", "only for the nested project")
res := cycle(t, a)
if res.LocalOps != 0 {
t.Fatalf("a journaled %d ops for nested-mount content, want 0", res.LocalOps)
}
// B must keep its copy (no delete propagated) and never see new files.
res = cycle(t, b)
if got := read(t, b.Folder, "team/notes.md"); got != "v1" {
t.Fatalf("b team/notes.md = %q after a's cycle, want v1 (no deletes)", got)
}
if _, err := os.Stat(filepath.Join(b.Folder, "team/local.md")); !os.IsNotExist(err) {
t.Fatal("nested-mount file leaked to peer")
}
// B edits inside team/; A must not materialize over its nested mount.
write(t, b.Folder, "team/notes.md", "v2")
cycle(t, b)
cycle(t, a)
if got := read(t, a.Folder, "team/notes.md"); got != "v1" {
t.Fatalf("a team/notes.md = %q, want v1 (nested mount not written)", got)
}
// Paths outside the nested mount keep syncing both ways.
write(t, a.Folder, "readme.md", "root v2")
cycle(t, a)
cycle(t, b)
if got := read(t, b.Folder, "readme.md"); got != "root v2" {
t.Fatalf("b readme.md = %q, want root v2", got)
}
}