operation_claim_integration_test.go RawHistoryFolder
1package deployment_test
2
3import (
4 "context"
5 "sync"
6 "testing"
7
8 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14 "github.com/bnema/gordon/internal/domain"
15 "github.com/bnema/gordon/internal/usecase/deployment"
16)
17
18// TestRemove_ConcurrentSameKeyExecutesOnce proves the guarantee the store
19// claim exists for, end to end: two concurrent requests carrying the same
20// idempotency key run the removal effects exactly once, and both callers
21// observe the same journal.
22func TestRemove_ConcurrentSameKeyExecutesOnce(t *testing.T) {
23 ctx := context.Background()
24 store := newTestStore(t)
25 runtime := outmocks.NewMockContainerRuntime(t)
26
27 active := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
28 "web": {Container: "c-1", EffectiveRevision: "rev-1", Spec: headlessSpec()},
29 }}
30 require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog"}))
31 require.NoError(t, store.SaveActive(ctx, active))
32
33 // Removal effects must run exactly once for the key.
34 runtime.EXPECT().StopContainer(mock.Anything, "c-1", mock.Anything).Return(nil).Once()
35 runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", false).Return(nil).Once()
36
37 svc := deployment.NewService(deployment.Deps{
38 State: store, Runtime: runtime,
39 }, zerowrap.Default())
40
41 const racers = 4
42 var (
43 wg sync.WaitGroup
44 mu sync.Mutex
45 ops []string
46 errs []error
47 )
48 start := make(chan struct{})
49 for range racers {
50 wg.Add(1)
51 go func() {
52 defer wg.Done()
53 <-start
54 result, err := svc.Remove(ctx, "blog", "key-1")
55 mu.Lock()
56 defer mu.Unlock()
57 errs = append(errs, err)
58 if result != nil {
59 ops = append(ops, result.Op)
60 }
61 }()
62 }
63 close(start)
64 wg.Wait()
65
66 require.Len(t, errs, racers)
67 for i, err := range errs {
68 require.NoError(t, err, "racer %d", i)
69 }
70 require.Len(t, ops, racers)
71 for i, op := range ops {
72 assert.Equal(t, "key-1", op, "racer %d must observe the claimed key", i)
73 }
74 runtime.AssertNumberOfCalls(t, "StopContainer", 1)
75 runtime.AssertNumberOfCalls(t, "RemoveContainer", 1)
76}
77
78// TestRemove_UnknownAppCreatesNoState proves a keyed mutation of an
79// unknown name fails with ErrAppNotFound and leaves no durable app state
80// behind, even with a real store.
81func TestRemove_UnknownAppCreatesNoState(t *testing.T) {
82 ctx := context.Background()
83 store := newTestStore(t)
84 runtime := outmocks.NewMockContainerRuntime(t)
85
86 svc := deployment.NewService(deployment.Deps{
87 State: store, Runtime: runtime,
88 }, zerowrap.Default())
89
90 _, err := svc.Remove(ctx, "ghost", "key-1")
91 require.ErrorIs(t, err, domain.ErrAppNotFound)
92
93 apps, err := store.ListApps(ctx)
94 require.NoError(t, err)
95 assert.NotContains(t, apps, "ghost")
96 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
97}