oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package store_test
2
3import (
4 "errors"
5 "path/filepath"
6 "testing"
7
8 "gorm.io/gorm"
9
10 "amadan.net/oficina/sheets/internal/sheets/calc"
11 "amadan.net/oficina/sheets/internal/sheets/seal"
12 "amadan.net/oficina/sheets/internal/sheets/store"
13)
14
15// Fixed ids seeded() uses, so every test can reference a cell by name
16// instead of threading ids through the test body.
17const (
18 tabID uint64 = 10
19 rowA uint64 = 100 // position 0
20 rowB uint64 = 101 // position 1
21 colA uint64 = 200 // position 0
22 actor uint64 = 7
23)
24
25// seeded builds a real database (through rastrillo's driver, WAL mode —
26// the same reasoning as store/helpers_test.go's openTestDB, whose
27// migrate.Apply(sheets.BootSchema) already covers every sheets model a
28// handle touches), and seeds one tab with two rows, one column, and a
29// single literal cell at (rowA, colA) holding "5". It returns the db,
30// the sealer, and the tab id; rowA, rowB, colA and actor above are the
31// ids to address it with.
32func seeded(t *testing.T) (*gorm.DB, *seal.Sealer, uint64) {
33 t.Helper()
34 dir := t.TempDir()
35 db := openTestDB(t, filepath.Join(dir, "test.db"))
36
37 s, err := seal.New(make32())
38 if err != nil {
39 t.Fatalf("seal.New: %v", err)
40 }
41
42 if err := db.Create(&store.Tab{ID: tabID, SheetID: 1}).Error; err != nil {
43 t.Fatalf("seed tab: %v", err)
44 }
45 if err := db.Create([]*store.Row{
46 {ID: rowA, TabID: tabID, Position: 0},
47 {ID: rowB, TabID: tabID, Position: 1},
48 }).Error; err != nil {
49 t.Fatalf("seed rows: %v", err)
50 }
51 if err := db.Create(&store.Col{ID: colA, TabID: tabID, Position: 0}).Error; err != nil {
52 t.Fatalf("seed col: %v", err)
53 }
54
55 inputSealed, err := s.Seal("cell.input", []byte("5"))
56 if err != nil {
57 t.Fatalf("seal input: %v", err)
58 }
59 // CacheVersion is deliberately left at its zero value, behind
60 // InputVersion 1 — this cell is stale by construction, exactly the
61 // shape a freshly-imported or freshly-restored cell takes before
62 // anything has ever recomputed it. That forces every test's first
63 // Open to run a real recompute for A1 rather than trusting a
64 // hand-seeded placeholder cache, which would otherwise silently
65 // stand in for "5" without ever being checked against it.
66 if err := db.Create(&store.Cell{
67 TabID: tabID, RowID: rowA, ColID: colA,
68 InputSealed: inputSealed, InputVersion: 1,
69 }).Error; err != nil {
70 t.Fatalf("seed cell: %v", err)
71 }
72
73 return db, s, tabID
74}
75
76// This is the test that matters. A crash between the input write and its
77// dependents' rewrite leaves cached values disagreeing with inputs —
78// silently, and forever, unless the handle detects it.
79func TestStaleCacheIsRecomputedOnOpen(t *testing.T) {
80 db, sealer, tabID := seeded(t)
81
82 h, err := store.Open(db, sealer, tabID)
83 if err != nil {
84 t.Fatalf("Open: %v", err)
85 }
86 if err := h.Set(rowB, colA, "=A1*2", actor); err != nil {
87 t.Fatalf("Set: %v", err)
88 }
89 h.Release()
90
91 // Simulate a crash mid-recalc: bump A1's input version without
92 // rewriting B1's cache. This is exactly what a panic between the two
93 // writes leaves behind.
94 db.Model(&store.Cell{}).
95 Where("row_id = ? AND col_id = ?", rowA, colA).
96 Updates(map[string]any{"input_version": 99})
97
98 h2, err := store.Open(db, sealer, tabID)
99 if err != nil {
100 t.Fatal(err)
101 }
102 defer h2.Release()
103
104 var b store.Cell
105 db.Where("row_id = ? AND col_id = ?", rowB, colA).First(&b)
106 if b.CacheVersion < 99 {
107 t.Fatalf("stale cache served: CacheVersion %d < InputVersion 99", b.CacheVersion)
108 }
109
110 // The value itself must also be correct, not just the version stamp:
111 // A1 is still 5 (only its version was tampered, not its content), so
112 // B1 = A1*2 must read 10, both from the handle and from what got
113 // persisted.
114 if got := h2.Value(rowB, colA); got.Kind != calc.KindNum || got.Num != 10 {
115 t.Fatalf("B1 = %+v, want KindNum 10", got)
116 }
117}
118
119// A tab past the cap fails honestly rather than dying slowly.
120func TestCellCapIsAnHonestError(t *testing.T) {
121 dir := t.TempDir()
122 db := openTestDB(t, filepath.Join(dir, "test.db"))
123 s, err := seal.New(make32())
124 if err != nil {
125 t.Fatal(err)
126 }
127
128 const oversizedTabID uint64 = 999
129 seedOversizedTab(t, db, oversizedTabID, store.MaxCellsPerTab+1)
130
131 _, err = store.Open(db, s, oversizedTabID)
132 if !errors.Is(err, store.ErrTabTooLarge) {
133 t.Fatalf("oversized tab: %v, want ErrTabTooLarge", err)
134 }
135}
136
137// seedOversizedTab inserts n minimal, content-free cell rows for tabID —
138// enough to trip the cap check, which counts rows before Open ever tries
139// to decrypt one. Raw, batched SQL rather than sealer.Seal + GORM.Create
140// per row: sealing and decrypting MaxCellsPerTab+1 rows for a test that
141// only exercises the COUNT(*) short-circuit would make this test
142// minutes slower for no coverage gained.
143func seedOversizedTab(t *testing.T, db *gorm.DB, tabID uint64, n int) {
144 t.Helper()
145 const batch = 500
146 for start := 0; start < n; start += batch {
147 end := start + batch
148 if end > n {
149 end = n
150 }
151 values := make([]any, 0, (end-start)*3)
152 placeholders := ""
153 for i := start; i < end; i++ {
154 if i > start {
155 placeholders += ","
156 }
157 placeholders += "(?,?,?)"
158 values = append(values, tabID, uint64(i+1), uint64(1))
159 }
160 sql := "INSERT INTO cells (tab_id, row_id, col_id) VALUES " + placeholders
161 if err := db.Exec(sql, values...).Error; err != nil {
162 t.Fatalf("seed oversized tab: %v", err)
163 }
164 }
165}
166