Files
beardrive/internal/webapp/restore_test.go
T
4ff92c56ab feat(history): group agent runs and restore any version (BEA-6) (#69)
* feat(history): group agent runs and restore any version (BEA-6)

BearDrive recorded everything and could restore nothing. Now every version
of a file has a Restore button — in the hub's History view and as
`bdrive restore` — and the changes one agent run made read as one card
instead of N loose rows.

Restore is a NEW put op pointing at the old blob: journals are never
rewritten, so one-writer-per-journal holds and peers converge on the
restore like any other edit. The hub reuses RemoteSource.Commit (the
upload commit minus the upload); the CLI writes the bytes into the working
folder and lets the ordinary cycle journal them, so the sync engine gains
no new write path.

Grouping is a pure frontend group-by on (note, device) over the existing
/history response — no journal or API change.

Known gap, stated in the UI and the docs: nothing in the hub writes a
delete op yet, so a file a run *created* cannot be un-created.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(history): don't repeat a run's note on every row in its card

UI pass on the real hub: inside a run card the note is the card's header, so
printing it again on each row said the same thing N times. The header now
carries the note (linkified, so an agent's session link still opens) and the
collapse control is its own button rather than the whole header — the link
could not live inside a button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:15:46 +09:00

233 lines
7.6 KiB
Go

package webapp
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/internal/journal"
)
// journalsAt reads every journal file under a project's storage prefix, so a
// test can prove a write touched only the server's own.
func journalsAt(t *testing.T, dir string) map[string]string {
t.Helper()
entries, err := os.ReadDir(filepath.Join(dir, "journal"))
if err != nil {
t.Fatal(err)
}
out := map[string]string{}
for _, e := range entries {
b, err := os.ReadFile(filepath.Join(dir, "journal", e.Name()))
if err != nil {
t.Fatal(err)
}
out[e.Name()] = string(b)
}
return out
}
func historyOf(t *testing.T, h http.Handler, base, path string) []HistoryEntry {
t.Helper()
rec := do(t, h, "GET", base+"history?path="+path, nil)
var out struct {
Entries []HistoryEntry `json:"entries"`
}
mustJSON(t, rec, &out)
return out.Entries
}
// Restoring appends a new put pointing at the old blob: the file serves the
// historical content again, and the restore itself is visible in history.
func TestRestoreWritesNewOp(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "notes/plan.md", "v1")
f.put("dev1", "notes/plan.md", "v2 longer")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "notes/plan.md", "sha": shaOf("v1")})
var out struct {
OK bool `json:"ok"`
Blob string `json:"blob"`
Size int64 `json:"size"`
}
mustJSON(t, rec, &out)
if !out.OK || out.Blob != shaOf("v1") || out.Size != int64(len("v1")) {
t.Fatalf("restore response = %+v", out)
}
if rec := do(t, h, "GET", base+"file?path=notes/plan.md", nil); rec.Body.String() != "v1" {
t.Fatalf("file after restore = %q, want v1", rec.Body)
}
entries := historyOf(t, h, base, "notes/plan.md")
if len(entries) != 3 {
t.Fatalf("history = %d entries, want 3", len(entries))
}
newest := entries[0]
want := "restore notes/plan.md@" + shaOf("v1")[:8]
if newest.Note != want {
t.Fatalf("restore note = %q, want %q", newest.Note, want)
}
if newest.Blob != shaOf("v1") || newest.Size != int64(len("v1")) {
t.Fatalf("restore entry = %+v", newest)
}
}
// A blob that exists but was never a version of THIS path must not be
// pasteable onto it.
func TestRestoreUnknownVersion(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "a.md", "mine")
f.put("dev1", "b.md", "someone else's")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
for _, sha := range []string{shaOf("b.md content that is elsewhere"), shaOf("someone else's")} {
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "a.md", "sha": sha})
if rec.Code != http.StatusNotFound {
t.Fatalf("restore of a foreign blob: %d %s, want 404", rec.Code, rec.Body)
}
}
// a malformed sha never reaches the journals either
if rec := do(t, h, "POST", base+"restore", map[string]string{"path": "a.md", "sha": "nope"}); rec.Code != http.StatusBadRequest {
t.Fatalf("bad sha: %d, want 400", rec.Code)
}
if got := journalsAt(t, dir); len(got) != len(before) {
t.Fatalf("a refused restore wrote a journal: %v → %v", before, got)
}
for name, data := range before {
if got := journalsAt(t, dir)[name]; got != data {
t.Fatalf("journal %s changed on a refused restore", name)
}
}
}
// One writer per journal: the hub appends to its own key and to nothing else.
func TestRestoreOnlyTouchesOwnJournal(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "f.md", "v1")
f.put("dev2", "f.md", "v2")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code != 200 {
t.Fatalf("restore: %d %s", rec.Code, rec.Body)
}
after := journalsAt(t, dir)
for _, dev := range []string{"dev1.jsonl", "dev2.jsonl"} {
if after[dev] != before[dev] {
t.Fatalf("restore rewrote %s", dev)
}
}
own := after[webDevice.ID+".jsonl"]
if own == "" || !strings.HasPrefix(own, before[webDevice.ID+".jsonl"]) {
t.Fatal("the server's own journal was not appended to")
}
ops, err := journal.Parse([]byte(own))
if err != nil || len(ops) != 1 {
t.Fatalf("own journal ops = %d (%v), want 1", len(ops), err)
}
}
// A blocked plan blocks restores too, even though a restore stores no new
// bytes — and nothing is journaled when it does.
func TestRestoreQuotaBlocked(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "f.md", "v1")
f.put("dev1", "f.md", "v2")
q := &recQuota{denyW: true}
srv.Quota = q
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("blocked restore: %d %s, want 413", rec.Code, rec.Body)
}
if len(q.usage) != 0 {
t.Fatalf("a denied restore recorded usage: %+v", q.usage)
}
if len(q.writes) != 1 || q.writes[0].bytes != 0 {
t.Fatalf("CheckWrite calls = %+v, want one for 0 bytes", q.writes)
}
for name, data := range journalsAt(t, dir) {
if data != before[name] {
t.Fatalf("denied restore wrote journal %s", name)
}
}
// allowed again: it goes through, and records zero bytes
q.denyW = false
if rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v1")}); rec.Code != 200 {
t.Fatalf("restore after unblocking: %d %s", rec.Code, rec.Body)
}
if len(q.usage) != 1 || q.usage[0].bytes != 0 {
t.Fatalf("RecordUsage = %+v, want one call for 0 bytes", q.usage)
}
}
// A deleted file comes back: the blob outlives the delete op.
func TestRestoreAfterDelete(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "gone.md", "still here")
f.del("dev1", "gone.md")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
if rec := do(t, h, "GET", base+"tree", nil); strings.Contains(rec.Body.String(), "gone.md") {
t.Fatal("file should be deleted before the restore")
}
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "gone.md", "sha": shaOf("still here")})
if rec.Code != 200 {
t.Fatalf("restore: %d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "GET", base+"tree", nil); !strings.Contains(rec.Body.String(), "gone.md") {
t.Fatalf("file did not come back: %s", rec.Body)
}
if rec := do(t, h, "GET", base+"file?path=gone.md", nil); rec.Body.String() != "still here" {
t.Fatalf("restored content = %q", rec.Body)
}
}
// Restore is a write: a hub running without --upload stays read-only.
func TestRestoreNeedsUploadsEnabled(t *testing.T) {
srv, p, root := newHub(t, false, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.put("dev1", "f.md", "v1")
h := srv.Handler()
rec := do(t, h, "POST", "/api/p/"+p.ID+"/restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code != http.StatusForbidden {
t.Fatalf("restore on a read-only hub: %d %s, want 403", rec.Code, rec.Body)
}
}
// The single-volume (plain folder) server has no journal to look a version
// up in, so it has no restore route at all.
func TestRestoreNotOnSingleVolume(t *testing.T) {
f := newFakeRemote(t)
f.put("dev1", "f.md", "v1")
h := f.uploadServer(nil).Handler()
rec := do(t, h, "POST", "/api/restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code < 400 {
t.Fatalf("single-volume restore: %d, want no such route", rec.Code)
}
}