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}