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}