v0.2.0 80c61e3

catalogue_test.go RawHistoryFolder

1package persist
2
3import (
4	"os"
5	"path/filepath"
6	"testing"
7
8	"github.com/stretchr/testify/require"
9
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/pkg/kv"
12	"github.com/bnema/vev/pkg/safedir"
13)
14
15func TestCatalogue(t *testing.T) {
16	t.Run("record-round-trip", testCatalogueRecordRoundTrip)
17	t.Run("duplicate-incarnations", testCatalogueDuplicateIncarnations)
18	t.Run("open-fails-closed", testCatalogueOpenFailsClosed)
19	t.Run("apply-rename-replace", testCatalogueApplyRenameReplace)
20	t.Run("metadata-update-preserves-authority", testCatalogueMetadataUpdatePreservesAuthority)
21	t.Run("metadata-update-is-deferred", testCatalogueMetadataUpdateIsDeferred)
22	t.Run("read-only-malformed", testCatalogueLoadReadOnlyRejectsMalformedValue)
23	t.Run("read-only-fresh-install", testCatalogueLoadReadOnlyFreshInstallHasNoSessions)
24	t.Run("read-only-ignores-stray-tmp", testCatalogueLoadReadOnlyIgnoresStrayTmp)
25}
26
27func testCatalogueRecordRoundTrip(t *testing.T) {
28	ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}}
29	tests := []domain.CatalogueRecord{
30		{Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp"},
31		{Name: "healthy", IncarnationID: domain.IncarnationID{2}, Committed: ref1},
32		{Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref1, DegradedReason: "invalid manifest"},
33		{Name: "other-fresh", IncarnationID: domain.IncarnationID{5}},
34	}
35	for _, record := range tests {
36		t.Run(record.Name, func(t *testing.T) {
37			encoded, err := encodeRecordValue(record)
38			require.NoError(t, err)
39			got, err := decodeRecordValue(record.Name, encoded)
40			require.NoError(t, err)
41			require.Equal(t, record, got)
42			for prefix := range len(encoded) {
43				_, err := decodeRecordValue(record.Name, encoded[:prefix])
44				require.Error(t, err, "prefix length %d", prefix)
45			}
46			require.Error(t, func() error { _, err := decodeRecordValue(record.Name, append(encoded, 0)); return err }())
47		})
48	}
49}
50
51func TestDecodeRecordValueRejectsMalformedCheckpointMarker(t *testing.T) {
52	encoded, err := encodeRecordValue(validRecord("work", 1))
53	require.NoError(t, err)
54	encoded[checkpointMarkerOffset(t, encoded)] = 99
55
56	_, err = decodeRecordValue("work", encoded)
57	require.ErrorIs(t, err, errMalformedRecord)
58}
59
60func checkpointMarkerOffset(t *testing.T, encoded []byte) int {
61	t.Helper()
62	r := valueReader{data: encoded}
63	_, ok := r.take(len(catalogueMagic) + 2 + len(domain.IncarnationID{}))
64	require.True(t, ok)
65	_, ok = r.str()
66	require.True(t, ok)
67	_, ok = r.take(8 + 8 + 8)
68	require.True(t, ok)
69	count, ok := r.u32()
70	require.True(t, ok)
71	for range count {
72		_, ok = r.str()
73		require.True(t, ok)
74	}
75	return len(encoded) - r.remaining()
76}
77
78func testCatalogueDuplicateIncarnations(t *testing.T) {
79	id := domain.IncarnationID{1}
80	require.Error(t, validateUniqueIncarnations([]domain.CatalogueRecord{
81		{Name: "one", IncarnationID: id},
82		{Name: "two", IncarnationID: id},
83	}))
84}
85
86func testCatalogueOpenFailsClosed(t *testing.T) {
87	dir := privateDir(t)
88	_, _, err := openCurrentCatalogue(dir, false)
89	require.Error(t, err)
90
91	require.NoError(t, safedir.EnsurePrivate(dir))
92	raw := []byte("corrupt catalogue")
93	require.NoError(t, os.WriteFile(StorePath(dir), raw, 0o600))
94	_, _, err = openCurrentCatalogue(dir, false)
95	require.ErrorIs(t, err, kv.ErrCorrupt)
96	after, readErr := os.ReadFile(StorePath(dir))
97	require.NoError(t, readErr)
98	require.Equal(t, raw, after)
99}
100
101func testCatalogueApplyRenameReplace(t *testing.T) {
102	dir := privateDir(t)
103	p, records, err := openCurrentCatalogue(dir, true)
104	require.NoError(t, err)
105	require.Empty(t, records)
106	defer func() { require.NoError(t, p.Close()) }()
107
108	one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}}
109	two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}}
110	require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": &one, "two": &two}))
111	renamed := one
112	renamed.Name = "renamed"
113	require.NoError(t, p.Rename("one", renamed))
114	two.Cwd = "/next"
115	require.NoError(t, p.Replace("two", two))
116
117	got, err := p.LoadCatalogue()
118	require.NoError(t, err)
119	require.ElementsMatch(t, []domain.CatalogueRecord{renamed, two}, got)
120}
121
122func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
123	dir := privateDir(t)
124	p, _, err := openCurrentCatalogue(dir, true)
125	require.NoError(t, err)
126	defer func() { require.NoError(t, p.Close()) }()
127
128	committed := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}}
129	original := domain.CatalogueRecord{
130		Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11,
131		UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"},
132		Committed: committed, DegradedReason: "repair pending",
133	}
134	require.NoError(t, p.Create(original))
135
136	next := domain.CatalogueRecord{Name: "work", IncarnationID: original.IncarnationID, Cwd: "/new", UpdatedAt: 22, LastUsedSeq: 23, TabNames: []string{"editor", "logs"}}
137	update := next.MetadataUpdate()
138	require.NoError(t, p.UpdateMetadata(update))
139	got, ok, err := p.Record("work")
140	require.NoError(t, err)
141	require.True(t, ok)
142	expected := original
143	expected.Cwd, expected.UpdatedAt, expected.LastUsedSeq, expected.TabNames = next.Cwd, next.UpdatedAt, next.LastUsedSeq, next.TabNames
144	require.Equal(t, expected, got)
145
146	lastUsedSeq := uint64(24)
147	require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
148		Name: "work", IncarnationID: original.IncarnationID, LastUsedSeq: &lastUsedSeq,
149	}))
150	expected.LastUsedSeq = lastUsedSeq
151	partiallyUpdated, ok, err := p.Record("work")
152	require.NoError(t, err)
153	require.True(t, ok)
154	require.Equal(t, expected, partiallyUpdated, "a partial metadata update must not clear other mutable or authority-owned fields")
155
156	stale := update
157	stale.IncarnationID = domain.IncarnationID{2}
158	require.Error(t, p.UpdateMetadata(stale))
159	unchanged, ok, err := p.Record("work")
160	require.NoError(t, err)
161	require.True(t, ok)
162	require.Equal(t, expected, unchanged)
163}
164
165func testCatalogueMetadataUpdateIsDeferred(t *testing.T) {
166	dir := privateDir(t)
167	p, _, err := openCurrentCatalogue(dir, true)
168	require.NoError(t, err)
169	defer func() { require.NoError(t, p.Close()) }()
170
171	record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old"}
172	require.NoError(t, p.Create(record))
173	before, err := os.ReadFile(StorePath(dir))
174	require.NoError(t, err)
175
176	cwd := "/buffered"
177	require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
178		Name: record.Name, IncarnationID: record.IncarnationID, Cwd: &cwd,
179	}))
180	afterBuffered, err := os.ReadFile(StorePath(dir))
181	require.NoError(t, err)
182	require.Equal(t, before, afterBuffered, "metadata must remain buffered before Sync")
183
184	require.NoError(t, p.Sync())
185	afterSync, err := os.ReadFile(StorePath(dir))
186	require.NoError(t, err)
187	require.NotEqual(t, before, afterSync)
188
189	cwd = "/next-identity"
190	require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
191		Name: record.Name, IncarnationID: record.IncarnationID, Cwd: &cwd,
192	}))
193	record.Cwd = cwd
194	record.UpdatedAt = 2
195	require.NoError(t, p.Replace(record.Name, record))
196	afterIdentity, err := os.ReadFile(StorePath(dir))
197	require.NoError(t, err)
198	require.NotEqual(t, afterSync, afterIdentity, "identity writes must sync before returning")
199}
200
201func testCatalogueLoadReadOnlyRejectsMalformedValue(t *testing.T) {
202	dir := privateDir(t)
203	store, err := kv.Open(filepath.Join(dir, filename))
204	require.NoError(t, err)
205	require.NoError(t, store.Set([]byte("work"), []byte("malformed")))
206	require.NoError(t, store.Close())
207	_, err = LoadCatalogueReadOnly(dir)
208	require.Error(t, err)
209}
210
211// testCatalogueLoadReadOnlyFreshInstallHasNoSessions covers `vev ls` on a
212// machine with no daemon and no catalogue. This must yield an empty catalogue,
213// not an error.
214func testCatalogueLoadReadOnlyFreshInstallHasNoSessions(t *testing.T) {
215	dir := privateDir(t)
216	records, err := LoadCatalogueReadOnly(dir)
217	require.NoError(t, err)
218	require.Empty(t, records)
219}
220
221func testCatalogueLoadReadOnlyIgnoresStrayTmp(t *testing.T) {
222	dir := privateDir(t)
223	p, _, err := openCurrentCatalogue(dir, true)
224	require.NoError(t, err)
225	want := validRecord("work", 1)
226	require.NoError(t, p.Create(want))
227	require.NoError(t, p.Close())
228	require.NoError(t, os.WriteFile(StorePath(dir)+".tmp", []byte("partial rewrite"), 0o600))
229
230	records, err := LoadCatalogueReadOnly(dir)
231	require.NoError(t, err)
232	require.Equal(t, []domain.CatalogueRecord{want}, records)
233	tmp, err := os.ReadFile(StorePath(dir) + ".tmp")
234	require.NoError(t, err)
235	require.Equal(t, []byte("partial rewrite"), tmp)
236}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close