1package appstate_test
2
3import (
4 "context"
5 "sync"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/gordon/internal/adapters/out/appstate"
13 "github.com/bnema/gordon/internal/domain"
14)
15
16// knownApp gives the app a live identity so keyed mutations are allowed.
17func knownApp(t *testing.T, store interface {
18 SaveOwnership(context.Context, domain.AppOwnership) error
19}, app string) {
20 t.Helper()
21 require.NoError(t, store.SaveOwnership(context.Background(), domain.AppOwnership{App: app}))
22}
23
24func deployClaim(app, key, revision string) domain.AppOperation {
25 return domain.AppOperation{
26 Op: key,
27 Kind: "deploy",
28 App: app,
29 StartedAt: time.Now().UTC(),
30 Request: domain.AppOperationRequestFor("deploy", app, revision, ""),
31 }
32}
33
34// TestStore_ClaimOperationCreatesInFlightJournal proves an absent key is
35// persisted as an in-flight claim so no effect can run twice.
36func TestStore_ClaimOperationCreatesInFlightJournal(t *testing.T) {
37 ctx := context.Background()
38 store := newTestStore(t)
39 knownApp(t, store, "blog")
40
41 candidate := deployClaim("blog", "key-1", "rev-1")
42 existing, claimed, err := store.ClaimOperation(ctx, candidate)
43 require.NoError(t, err)
44 require.True(t, claimed)
45 assert.Equal(t, "key-1", existing.Op)
46 assert.False(t, existing.Terminal(), "a fresh claim is not terminal")
47
48 stored, err := store.LoadOperation(ctx, "blog", "key-1")
49 require.NoError(t, err)
50 assert.Equal(t, candidate.Request, stored.Request)
51 assert.False(t, stored.Terminal())
52}
53
54// TestStore_ClaimOperationReplaysSameRequest proves a repeat of one
55// request returns the stored journal instead of claiming again.
56func TestStore_ClaimOperationReplaysSameRequest(t *testing.T) {
57 ctx := context.Background()
58 store := newTestStore(t)
59 knownApp(t, store, "blog")
60
61 require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{
62 Op: "key-1", Kind: "deploy", App: "blog",
63 Request: domain.AppOperationRequestFor("deploy", "blog", "rev-1", ""),
64 Outcome: domain.AppOutcomeSuccess,
65 StartedAt: time.Now().UTC(),
66 }))
67
68 existing, claimed, err := store.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-1"))
69 require.NoError(t, err)
70 assert.False(t, claimed)
71 assert.Equal(t, domain.AppOutcomeSuccess, existing.Outcome)
72}
73
74// TestStore_ClaimOperationConflictsOnDifferentRequest proves one key can
75// never answer two different requests.
76func TestStore_ClaimOperationConflictsOnDifferentRequest(t *testing.T) {
77 ctx := context.Background()
78 store := newTestStore(t)
79 knownApp(t, store, "blog")
80
81 _, claimed, err := store.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-1"))
82 require.NoError(t, err)
83 require.True(t, claimed)
84
85 _, _, err = store.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-2"))
86 require.ErrorIs(t, err, domain.ErrAppStateConflict)
87
88 // A different kind with the same key is a different request too.
89 stop := domain.AppOperation{
90 Op: "key-1", Kind: "stop", App: "blog", StartedAt: time.Now().UTC(),
91 Request: domain.AppOperationRequestFor("stop", "blog", "", ""),
92 }
93 _, _, err = store.ClaimOperation(ctx, stop)
94 require.ErrorIs(t, err, domain.ErrAppStateConflict)
95}
96
97// TestStore_ClaimOperationRefusesUnknownAppWithoutState proves a keyed
98// mutation of an unknown name creates no durable state at all.
99func TestStore_ClaimOperationRefusesUnknownAppWithoutState(t *testing.T) {
100 ctx := context.Background()
101 store := newTestStore(t)
102
103 _, _, err := store.ClaimOperation(ctx, deployClaim("ghost", "key-1", "rev-1"))
104 require.ErrorIs(t, err, domain.ErrAppNotFound)
105
106 apps, err := store.ListApps(ctx)
107 require.NoError(t, err)
108 assert.NotContains(t, apps, "ghost", "a refused claim must not create an app entry")
109
110 _, err = store.LoadOperation(ctx, "ghost", "key-1")
111 require.ErrorIs(t, err, domain.ErrAppOperationNotFound)
112}
113
114// TestStore_ClaimOperationIsAtomicUnderConcurrency proves two concurrent
115// claims of one key produce exactly one winner.
116func TestStore_ClaimOperationIsAtomicUnderConcurrency(t *testing.T) {
117 ctx := context.Background()
118 store := newTestStore(t)
119 knownApp(t, store, "blog")
120
121 const racers = 8
122 var (
123 wg sync.WaitGroup
124 mu sync.Mutex
125 winners int
126 )
127 start := make(chan struct{})
128 for range racers {
129 wg.Add(1)
130 go func() {
131 defer wg.Done()
132 <-start
133 _, claimed, err := store.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-1"))
134 mu.Lock()
135 defer mu.Unlock()
136 if err != nil {
137 t.Errorf("claim: %v", err)
138 return
139 }
140 if claimed {
141 winners++
142 }
143 }()
144 }
145 close(start)
146 wg.Wait()
147 assert.Equal(t, 1, winners, "exactly one claim may win")
148}
149
150// TestStore_ClaimOperationReplaysAfterReopen proves a claimed operation
151// survives a daemon restart: the reopened store replays the stored journal
152// instead of claiming the key again.
153func TestStore_ClaimOperationReplaysAfterReopen(t *testing.T) {
154 ctx := context.Background()
155 dir := t.TempDir()
156 store, err := appstate.NewStore(dir, testLogger())
157 require.NoError(t, err)
158 require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog"}))
159
160 _, claimed, err := store.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-1"))
161 require.NoError(t, err)
162 require.True(t, claimed)
163 op := deployClaim("blog", "key-1", "rev-1")
164 op.Outcome = domain.AppOutcomeSuccess
165 require.NoError(t, store.SaveOperation(ctx, op))
166 require.NoError(t, store.Close())
167
168 reopened, err := appstate.NewStore(dir, testLogger())
169 require.NoError(t, err)
170 t.Cleanup(func() { assert.NoError(t, reopened.Close()) })
171
172 existing, claimed, err := reopened.ClaimOperation(ctx, deployClaim("blog", "key-1", "rev-1"))
173 require.NoError(t, err)
174 assert.False(t, claimed, "a reopened store replays the persisted claim")
175 assert.Equal(t, domain.AppOutcomeSuccess, existing.Outcome)
176}
177
178// TestStore_ClaimOperationSurvivesRetire proves an operation journal
179// stays reachable after removal while a new key against the freed name
180// is refused.
181func TestStore_ClaimOperationSurvivesRetire(t *testing.T) {
182 ctx := context.Background()
183 store := newTestStore(t)
184 knownApp(t, store, "blog")
185
186 remove := domain.AppOperation{
187 Op: "remove-1", Kind: "remove", App: "blog", StartedAt: time.Now().UTC(),
188 Request: domain.AppOperationRequestFor("remove", "blog", "", ""),
189 }
190 _, claimed, err := store.ClaimOperation(ctx, remove)
191 require.NoError(t, err)
192 require.True(t, claimed)
193 remove.Outcome = domain.AppOutcomeSuccess
194 remove.Steps = []domain.AppOperationStep{{ID: "service.web.remove", State: domain.AppStepSucceeded}}
195 require.NoError(t, store.SaveOperation(ctx, remove))
196 require.NoError(t, store.RetireApp(ctx, "blog"))
197
198 replayed, claimed, err := store.ClaimOperation(ctx, domain.AppOperation{
199 Op: "remove-1", Kind: "remove", App: "blog",
200 Request: domain.AppOperationRequestFor("remove", "blog", "", ""),
201 })
202 require.NoError(t, err)
203 assert.False(t, claimed, "a repeated remove replays its stored result")
204 assert.Equal(t, domain.AppOutcomeSuccess, replayed.Outcome)
205
206 _, _, err = store.ClaimOperation(ctx, domain.AppOperation{
207 Op: "remove-2", Kind: "remove", App: "blog",
208 Request: domain.AppOperationRequestFor("remove", "blog", "", ""),
209 })
210 require.ErrorIs(t, err, domain.ErrAppNotFound, "a new key must not resurrect a retired name")
211}