Files
beardrive/internal/webapp/db_conformance_test.go
Snow LeeandClaude Fable 5 28dc882c66 feat(web): read heatmap phase 1 — ledger, heat API, folder heat dots
Read telemetry per docs/design/read-heatmap.md: a ReadLedger over a new
batch-oriented MetaStore ReadRepo (file reads.json + SQL read_stats)
aggregates viewer and share reads into daily per-actor buckets, debounced
to visits, folded into all-time rows past retention. GET /api/p/<id>/heat
serves per-path counts (human/agent/share, distinct readers, last read) —
never identities. /store sync traffic and history blob views are not reads.
The viewer shows heat dots and read counts on folder listings and the file
meta line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
2026-07-11 14:41:07 -07:00

268 lines
7.5 KiB
Go

package webapp
import (
"database/sql"
"os"
"path/filepath"
"testing"
"time"
)
// A metaBackend is one MetaStore implementation under test. reset clears any
// shared durable state before the run; open returns a fresh store over the
// SAME underlying storage, so the suite can write, reopen, and prove the data
// persisted.
type metaBackend struct {
name string
reset func(t *testing.T)
open func(t *testing.T) MetaStore
}
func metaBackends(t *testing.T) []metaBackend {
dir := t.TempDir()
sqlitePath := filepath.Join(t.TempDir(), "meta.db")
backends := []metaBackend{
{
name: "file",
reset: func(t *testing.T) {},
open: func(t *testing.T) MetaStore {
s, err := OpenFileStore(dir)
if err != nil {
t.Fatal(err)
}
return s
},
},
{
name: "sqlite",
reset: func(t *testing.T) {},
open: func(t *testing.T) MetaStore {
s, err := OpenSQLStore("sqlite", sqlitePath)
if err != nil {
t.Fatal(err)
}
return s
},
},
}
// Postgres/Supabase is exercised only when a DSN is reachable.
if dsn := os.Getenv("BDRIVE_TEST_POSTGRES"); dsn != "" {
backends = append(backends, metaBackend{
name: "postgres",
reset: func(t *testing.T) {
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("postgres reset: %v", err)
}
defer db.Close()
db.Exec(`DROP TABLE IF EXISTS accounts, tokens, auth_policy, projects, orgs, org_members, invites, shares, devices, read_stats`)
},
open: func(t *testing.T) MetaStore {
s, err := OpenSQLStore("pgx", dsn)
if err != nil {
t.Fatal(err)
}
return s
},
})
} else {
t.Log("BDRIVE_TEST_POSTGRES not set — postgres backend UNTESTED in this run")
}
return backends
}
// TestMetaStoreConformance runs the same service-level operations against every
// backend, then reopens the store and asserts the data survived — covering
// accounts+tokens, the signup policy, pending/approve, projects (create-or-join,
// rename, delete), orgs (roles), invites (create/redeem/uses/validity), shares
// (create/revoke/expiry), and devices.
func TestMetaStoreConformance(t *testing.T) {
for _, be := range metaBackends(t) {
t.Run(be.name, func(t *testing.T) {
be.reset(t)
// ---- write everything through the services ----
st := be.open(t)
auth, err := NewBuiltinAuth(st.Accounts(), true, nil)
if err != nil {
t.Fatal(err)
}
// account + token
u, err := auth.signup("dev@x.io", "Dev", "password1")
if err != nil {
t.Fatal(err)
}
tok, err := auth.issueToken(u.ID, "cli")
if err != nil {
t.Fatal(err)
}
if _, ok := auth.userForToken(tok); !ok {
t.Fatal("token should authenticate its account")
}
// pending + policy
if err := auth.SetPolicy(false, true); err != nil { // require approval
t.Fatal(err)
}
pend, err := auth.signup("pending@x.io", "Pend", "password1")
if err != nil {
t.Fatal(err)
}
if pend.Status != statusPending {
t.Fatalf("new account status = %q, want pending", pend.Status)
}
projects, err := NewProjectDB(st.Projects())
if err != nil {
t.Fatal(err)
}
p1, created, err := projects.GetOrCreate("wiki", "o-1")
if err != nil || !created {
t.Fatalf("create wiki: created=%v err=%v", created, err)
}
if _, again, _ := projects.GetOrCreate("wiki", "o-1"); again {
t.Fatal("same name+org must join, not create")
}
if _, other, _ := projects.GetOrCreate("wiki", "o-2"); !other {
t.Fatal("same name in a different org must create")
}
if err := projects.Rename(p1.ID, "handbook"); err != nil {
t.Fatal(err)
}
p2, _, _ := projects.GetOrCreate("scratch", "o-1")
if err := projects.Delete(p2.ID); err != nil {
t.Fatal(err)
}
orgs, err := NewOrgDB(st.Orgs())
if err != nil {
t.Fatal(err)
}
org, err := orgs.Create("Acme", "boss@x.io")
if err != nil {
t.Fatal(err)
}
if err := orgs.AddMember(org.ID, "worker@x.io", RoleMember); err != nil {
t.Fatal(err)
}
if err := orgs.SetRole(org.ID, "worker@x.io", RoleOwner); err != nil {
t.Fatal(err)
}
inv, err := orgs.CreateInvite(org.ID, "boss@x.io", time.Hour)
if err != nil {
t.Fatal(err)
}
orgs.RecordInviteUse(inv.Token)
if !orgs.ValidInvite(inv.Token) {
t.Fatal("fresh invite should be valid")
}
shares, err := NewShareDB(st.Shares())
if err != nil {
t.Fatal(err)
}
live, err := shares.Create(p1.ID, "handbook.md", "boss@x.io", 0)
if err != nil {
t.Fatal(err)
}
gone, _ := shares.Create(p1.ID, "temp.md", "boss@x.io", time.Hour)
if !shares.Revoke(gone.Token) {
t.Fatal("revoke should succeed")
}
devices, err := NewDeviceRegistry(st.Devices())
if err != nil {
t.Fatal(err)
}
devices.Observe(DeviceInfo{ID: "d1", Name: "laptop", OS: "mac", User: "dev@x.io", IP: "1.2.3.4"})
reads, err := NewReadLedger(st.Reads(), 0)
if err != nil {
t.Fatal(err)
}
reads.Record(p1.ID, "handbook.md", ReadKindHuman, "dev@x.io")
reads.Record(p1.ID, "handbook.md", ReadKindHuman, "boss@x.io")
reads.Record(p1.ID, "wiki/deep.md", ReadKindAgent, "d1")
if err := reads.Close(); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
// ---- reopen and verify everything persisted ----
st2 := be.open(t)
defer st2.Close()
auth2, _ := NewBuiltinAuth(st2.Accounts(), true, nil)
if _, ok := auth2.userForToken(tok); !ok {
t.Fatal("token lost across reload")
}
if !auth2.RequireApproval || auth2.RequireVerification {
t.Fatal("policy lost across reload")
}
if got := auth2.PendingUsers(); len(got) != 1 || got[0].Email != "pending@x.io" {
t.Fatalf("pending users after reload = %+v", got)
}
if err := auth2.Approve(pend.ID); err != nil {
t.Fatal(err)
}
if len(auth2.PendingUsers()) != 0 {
t.Fatal("approve did not clear pending")
}
projects2, _ := NewProjectDB(st2.Projects())
list := projects2.List()
if len(list) != 2 { // handbook (o-1), wiki (o-2); scratch was deleted
t.Fatalf("projects after reload = %+v", list)
}
hb, ok := projects2.Get(p1.ID)
if !ok || hb.Name != "handbook" {
t.Fatalf("rename lost across reload: %+v", hb)
}
orgs2, _ := NewOrgDB(st2.Orgs())
ro, ok := orgs2.Get(org.ID)
if !ok || ro.Members["boss@x.io"] != RoleOwner || ro.Members["worker@x.io"] != RoleOwner {
t.Fatalf("org roles lost across reload: %+v", ro)
}
if !orgs2.ValidInvite(inv.Token) {
t.Fatal("invite lost across reload")
}
if got := orgs2.ListInvites(org.ID); len(got) != 1 || got[0].Uses != 1 {
t.Fatalf("invite uses after reload = %+v", got)
}
shares2, _ := NewShareDB(st2.Shares())
if _, ok := shares2.Get(live.Token); !ok {
t.Fatal("live share lost across reload")
}
if _, ok := shares2.Get(gone.Token); ok {
t.Fatal("revoked share came back after reload")
}
devices2, _ := NewDeviceRegistry(st2.Devices())
d, ok := devices2.Get("d1")
if !ok || d.Name != "laptop" || d.IP != "1.2.3.4" {
t.Fatalf("device lost across reload: %+v", d)
}
reads2, err := NewReadLedger(st2.Reads(), 0)
if err != nil {
t.Fatal(err)
}
heat := reads2.Heat(p1.ID, "", time.Time{})
if e := heat["handbook.md"]; e.Human != 2 || e.Readers != 2 {
t.Fatalf("read buckets lost across reload: %+v", e)
}
if e := heat["wiki/deep.md"]; e.Agent != 1 || e.Readers != 0 {
t.Fatalf("agent read bucket lost across reload: %+v", e)
}
if sub := reads2.Heat(p1.ID, "wiki", time.Time{}); len(sub) != 1 {
t.Fatalf("prefix heat = %+v, want only wiki/deep.md", sub)
}
})
}
}