v0.4.0 3027a40

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	require.NoError(t, s.Close(ctx))
148	assert.ErrorIs(t, s.Check(ctx), kv.ErrClosed)
149}
150
151func TestOpenLockedDirectory(t *testing.T) {
152	ctx := context.Background()
153	dir := t.TempDir()
154	_ = openStore(t, dir)
155	_, err := kvstore.Open(ctx, dir)
156	assert.ErrorIs(t, err, kv.ErrLocked)
157}
158
159// putRaw writes a value with ermite-kv directly, bypassing the store.
160func putRaw(t *testing.T, dir, key string, val []byte) {
161	t.Helper()
162	ctx := context.Background()
163	db, err := kv.Open(ctx, dir, kv.Options{})
164	require.NoError(t, err)
165	var b kv.Batch
166	b.Put([]byte(key), val)
167	_, err = db.Apply(ctx, &b)
168	require.NoError(t, err)
169	require.NoError(t, db.Close(ctx))
170}
171
172func readSchema(t *testing.T, dir string) uint32 {
173	t.Helper()
174	ctx := context.Background()
175	db, err := kv.Open(ctx, dir, kv.Options{})
176	require.NoError(t, err)
177	defer func() { _ = db.Close(ctx) }()
178	item, ok := db.Get(ctx, []byte("meta/schema"))
179	require.True(t, ok, "meta/schema missing")
180	var sv storagepb.SchemaVersion
181	require.NoError(t, proto.Unmarshal(item.Value, &sv))
182	return sv.GetVersion()
183}
184
185func TestSchema(t *testing.T) {
186	ctx := context.Background()
187
188	t.Run("missing is written as 1", func(t *testing.T) {
189		dir := t.TempDir()
190		s, err := kvstore.Open(ctx, dir)
191		require.NoError(t, err)
192		require.NoError(t, s.Close(ctx))
193		assert.Equal(t, uint32(1), readSchema(t, dir))
194	})
195
196	t.Run("current is accepted and kept", func(t *testing.T) {
197		dir := t.TempDir()
198		s, err := kvstore.Open(ctx, dir)
199		require.NoError(t, err)
200		require.NoError(t, s.Close(ctx))
201		s, err = kvstore.Open(ctx, dir)
202		require.NoError(t, err)
203		require.NoError(t, s.Close(ctx))
204		assert.Equal(t, uint32(1), readSchema(t, dir))
205	})
206
207	t.Run("newer stops startup and releases the lock", func(t *testing.T) {
208		dir := t.TempDir()
209		putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
210		_, err := kvstore.Open(ctx, dir)
211		require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
212		assert.Equal(t, uint32(2), readSchema(t, dir), "the lock was released and the value kept")
213	})
214
215	t.Run("garbage and zero are errors", func(t *testing.T) {
216		dir := t.TempDir()
217		putRaw(t, dir, "meta/schema", []byte{0xff, 0xff, 0xff})
218		_, err := kvstore.Open(ctx, dir)
219		assert.Error(t, err)
220
221		dir = t.TempDir()
222		putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 0}))
223		_, err = kvstore.Open(ctx, dir)
224		assert.Error(t, err)
225	})
226}
227
228func mustProto(t *testing.T, m proto.Message) []byte {
229	t.Helper()
230	b, err := proto.Marshal(m)
231	require.NoError(t, err)
232	return b
233}
234
235func TestUnspecifiedVisibilityReadsPrivate(t *testing.T) {
236	ctx := context.Background()
237	dir := t.TempDir()
238	putRaw(t, dir, "repo/odd", mustProto(t, &storagepb.Repo{Name: "odd"})) // visibility unset
239	putRaw(t, dir, "repo/future", mustProto(t, &storagepb.Repo{Name: "future", Visibility: storagepb.Visibility(42)}))
240	s := openStore(t, dir)
241
242	for _, n := range []string{"odd", "future"} {
243		got, err := s.Get(ctx, name(t, n))
244		require.NoError(t, err)
245		assert.Equal(t, domain.VisibilityPrivate, got.Visibility, n)
246	}
247}
248
249func TestCorruptRepoValue(t *testing.T) {
250	ctx := context.Background()
251	dir := t.TempDir()
252	putRaw(t, dir, "repo/bad", []byte{0xff, 0xff})
253	putRaw(t, dir, "repo/mismatch", mustProto(t, &storagepb.Repo{Name: "Not Valid"}))
254	s := openStore(t, dir)
255
256	_, err := s.Get(ctx, name(t, "bad"))
257	assert.Error(t, err)
258	_, err = s.Get(ctx, name(t, "mismatch"))
259	assert.ErrorIs(t, err, domain.ErrInvalidName)
260	_, err = s.List(ctx)
261	assert.Error(t, err)
262}

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