v0.7.0 45bfc5b

store_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"sync"
10	"testing"
11	"time"
12
13	kv "bnema.dev/ermite-kv"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16	"google.golang.org/protobuf/proto"
17
18	storagepb "bnema.dev/ermite/gen/ermite/storage"
19	"bnema.dev/ermite/internal/adapters/kvstore"
20	"bnema.dev/ermite/internal/domain"
21)
22
23func openStore(t *testing.T, dir string) *kvstore.Store {
24	t.Helper()
25	ctx := context.Background()
26	s, err := kvstore.Open(ctx, dir)
27	require.NoError(t, err)
28	t.Cleanup(func() { _ = s.Close(ctx) })
29	return s
30}
31
32func name(t *testing.T, s string) domain.RepoName {
33	t.Helper()
34	n, err := domain.ParseRepoName(s)
35	require.NoError(t, err)
36	return n
37}
38
39var created = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
40
41func TestCreateGetListUpdateDelete(t *testing.T) {
42	ctx := context.Background()
43	s := openStore(t, t.TempDir())
44
45	b := domain.Repo{Name: name(t, "b"), Description: "second", Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
46	a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()}
47	require.NoError(t, s.Create(ctx, b))
48	require.NoError(t, s.Create(ctx, a))
49
50	got, err := s.Get(ctx, b.Name)
51	require.NoError(t, err)
52	assert.Equal(t, b, got)
53	got, err = s.Get(ctx, a.Name)
54	require.NoError(t, err)
55	assert.Equal(t, a, got)
56	assert.True(t, got.PushedAt.Equal(a.PushedAt))
57
58	list, err := s.List(ctx)
59	require.NoError(t, err)
60	assert.Equal(t, []domain.Repo{a, b}, list)
61
62	err = s.Create(ctx, b)
63	assert.ErrorIs(t, err, domain.ErrAlreadyExists)
64
65	b.Description = "changed"
66	b.Visibility = domain.VisibilityPrivate
67	require.NoError(t, s.Update(ctx, b))
68	got, err = s.Get(ctx, b.Name)
69	require.NoError(t, err)
70	assert.Equal(t, b, got)
71
72	require.NoError(t, s.Delete(ctx, b.Name))
73	_, err = s.Get(ctx, b.Name)
74	assert.ErrorIs(t, err, domain.ErrNotFound)
75	require.NoError(t, s.Delete(ctx, b.Name), "deleting a missing repository is not an error")
76	assert.ErrorIs(t, s.Update(ctx, b), domain.ErrNotFound)
77
78	_, err = s.Get(ctx, name(t, "never"))
79	assert.ErrorIs(t, err, domain.ErrNotFound)
80}
81
82func TestCreateRejectsInvalidValues(t *testing.T) {
83	ctx := context.Background()
84	s := openStore(t, t.TempDir())
85	n := name(t, "x")
86
87	assert.ErrorIs(t, s.Create(ctx, domain.Repo{Name: n, Visibility: "secret"}), domain.ErrInvalidVisibility)
88	assert.ErrorIs(t, s.Create(ctx, domain.Repo{Visibility: domain.VisibilityPublic}), domain.ErrInvalidName)
89	assert.Error(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "\xff"}))
90	list, err := s.List(ctx)
91	require.NoError(t, err)
92	assert.Empty(t, list)
93}
94
95func TestConcurrentCreateSameName(t *testing.T) {
96	ctx := context.Background()
97	s := openStore(t, t.TempDir())
98	n := name(t, "tools")
99
100	const workers = 16
101	errs := make(chan error, workers)
102	var wg sync.WaitGroup
103	for i := range workers {
104		wg.Add(1)
105		go func() {
106			defer wg.Done()
107			errs <- s.Create(ctx, domain.Repo{Name: n, Description: fmt.Sprint(i), Visibility: domain.VisibilityPrivate, CreatedAt: created})
108		}()
109	}
110	wg.Wait()
111	close(errs)
112	ok, exists := 0, 0
113	for err := range errs {
114		switch {
115		case err == nil:
116			ok++
117		case errors.Is(err, domain.ErrAlreadyExists):
118			exists++
119		default:
120			t.Fatalf("unexpected error: %v", err)
121		}
122	}
123	assert.Equal(t, 1, ok)
124	assert.Equal(t, workers-1, exists)
125}
126
127func TestPersistsAcrossReopen(t *testing.T) {
128	ctx := context.Background()
129	dir := t.TempDir()
130	s, err := kvstore.Open(ctx, dir)
131	require.NoError(t, err)
132	r := domain.Repo{Name: name(t, "keep"), Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
133	require.NoError(t, s.Create(ctx, r))
134	require.NoError(t, s.Close(ctx))
135
136	s2 := openStore(t, dir)
137	got, err := s2.Get(ctx, r.Name)
138	require.NoError(t, err)
139	assert.Equal(t, r, got)
140}
141
142func TestCheck(t *testing.T) {
143	ctx := context.Background()
144	s, err := kvstore.Open(ctx, t.TempDir())
145	require.NoError(t, err)
146	require.NoError(t, s.Check(ctx))
147
148	// The caller's own cancellation is reported, not mistaken for health.
149	cancelled, cancel := context.WithCancel(ctx)
150	cancel()
151	assert.ErrorIs(t, s.Check(cancelled), context.Canceled)
152
153	require.NoError(t, s.Close(ctx))
154	assert.ErrorIs(t, s.Check(ctx), kv.ErrClosed)
155}
156
157func TestOpenLockedDirectory(t *testing.T) {
158	ctx := context.Background()
159	dir := t.TempDir()
160	_ = openStore(t, dir)
161	_, err := kvstore.Open(ctx, dir)
162	assert.ErrorIs(t, err, kv.ErrLocked)
163}
164
165// putRaw writes a value with ermite-kv directly, bypassing the store.
166func putRaw(t *testing.T, dir, key string, val []byte) {
167	t.Helper()
168	ctx := context.Background()
169	db, err := kv.Open(ctx, dir, kv.Options{})
170	require.NoError(t, err)
171	var b kv.Batch
172	b.Put([]byte(key), val)
173	_, err = db.Apply(ctx, &b)
174	require.NoError(t, err)
175	require.NoError(t, db.Close(ctx))
176}
177
178func readSchema(t *testing.T, dir string) uint32 {
179	t.Helper()
180	ctx := context.Background()
181	db, err := kv.Open(ctx, dir, kv.Options{})
182	require.NoError(t, err)
183	defer func() { _ = db.Close(ctx) }()
184	item, ok := db.Get(ctx, []byte("meta/schema"))
185	require.True(t, ok, "meta/schema missing")
186	var sv storagepb.SchemaVersion
187	require.NoError(t, proto.Unmarshal(item.Value, &sv))
188	return sv.GetVersion()
189}
190
191func TestSchema(t *testing.T) {
192	ctx := context.Background()
193
194	t.Run("missing is written as the current version", func(t *testing.T) {
195		dir := t.TempDir()
196		s, err := kvstore.Open(ctx, dir)
197		require.NoError(t, err)
198		require.NoError(t, s.Close(ctx))
199		assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
200	})
201
202	t.Run("current is accepted and kept", func(t *testing.T) {
203		dir := t.TempDir()
204		s, err := kvstore.Open(ctx, dir)
205		require.NoError(t, err)
206		require.NoError(t, s.Close(ctx))
207		s, err = kvstore.Open(ctx, dir)
208		require.NoError(t, err)
209		require.NoError(t, s.Close(ctx))
210		assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
211	})
212
213	t.Run("version 1 is upgraded to 2, its data kept", func(t *testing.T) {
214		dir := t.TempDir()
215		putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 1}))
216		putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
217		s, err := kvstore.Open(ctx, dir)
218		require.NoError(t, err)
219		got, err := s.Get(ctx, name(t, "old"))
220		require.NoError(t, err)
221		assert.Equal(t, "old", got.Name.String())
222		require.NoError(t, s.Close(ctx))
223		assert.Equal(t, uint32(2), readSchema(t, dir))
224	})
225
226	t.Run("newer stops startup and releases the lock", func(t *testing.T) {
227		dir := t.TempDir()
228		putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
229		_, err := kvstore.Open(ctx, dir)
230		require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
231		assert.Equal(t, uint32(3), readSchema(t, dir), "the lock was released and the value kept")
232	})
233
234	t.Run("garbage and zero are errors", func(t *testing.T) {
235		dir := t.TempDir()
236		putRaw(t, dir, "meta/schema", []byte{0xff, 0xff, 0xff})
237		_, err := kvstore.Open(ctx, dir)
238		assert.Error(t, err)
239
240		dir = t.TempDir()
241		putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 0}))
242		_, err = kvstore.Open(ctx, dir)
243		assert.Error(t, err)
244	})
245}
246
247func mustProto(t *testing.T, m proto.Message) []byte {
248	t.Helper()
249	b, err := proto.Marshal(m)
250	require.NoError(t, err)
251	return b
252}
253
254func TestUnspecifiedVisibilityReadsPrivate(t *testing.T) {
255	ctx := context.Background()
256	dir := t.TempDir()
257	putRaw(t, dir, "repo/odd", mustProto(t, &storagepb.Repo{Name: "odd"})) // visibility unset
258	putRaw(t, dir, "repo/future", mustProto(t, &storagepb.Repo{Name: "future", Visibility: storagepb.Visibility(42)}))
259	s := openStore(t, dir)
260
261	for _, n := range []string{"odd", "future"} {
262		got, err := s.Get(ctx, name(t, n))
263		require.NoError(t, err)
264		assert.Equal(t, domain.VisibilityPrivate, got.Visibility, n)
265	}
266}
267
268func TestCorruptRepoValue(t *testing.T) {
269	ctx := context.Background()
270	dir := t.TempDir()
271	putRaw(t, dir, "repo/bad", []byte{0xff, 0xff})
272	putRaw(t, dir, "repo/mismatch", mustProto(t, &storagepb.Repo{Name: "Not Valid"}))
273	s := openStore(t, dir)
274
275	_, err := s.Get(ctx, name(t, "bad"))
276	assert.Error(t, err)
277	_, err = s.Get(ctx, name(t, "mismatch"))
278	assert.ErrorIs(t, err, domain.ErrInvalidName)
279	_, err = s.List(ctx)
280	assert.Error(t, err)
281}

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 2

Tags 45

↑↓ moveenter openesc close