1package apps_test
2
3import (
4 "context"
5 "testing"
6 "time"
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 "github.com/bnema/gordon/internal/domain"
14 "github.com/bnema/gordon/internal/usecase/apps"
15)
16
17func TestAppService_Show_FreshDesiredOnlyApp(t *testing.T) {
18 ctx := context.Background()
19 store := newMockAppState(t)
20 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
21
22 store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
23 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{
24 App: "blog", Revision: "rev-1", Status: "pending",
25 }, true, nil).Once()
26 store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
27 store.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
28 store.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
29 store.EXPECT().LoadLatestOperation(mock.Anything, "blog").
30 Return(domain.AppOperation{}, false, nil).Once()
31
32 detail, err := svc.Show(ctx, "blog")
33 require.NoError(t, err)
34 assert.Equal(t, "rev-1", detail.DesiredRevision)
35 assert.Equal(t, "pending", detail.DesiredStatus)
36 assert.True(t, detail.Pending, "desired state ACTIVE never reached is pending")
37 assert.False(t, detail.Converged)
38 assert.Empty(t, detail.ConvergedRevision)
39 assert.Empty(t, detail.Services)
40 assert.Empty(t, detail.LastOp)
41 assert.Empty(t, detail.Retained.Volumes)
42}
43
44func TestAppService_Show_ConvergedAppWithRetainedResources(t *testing.T) {
45 ctx := context.Background()
46 store := newMockAppState(t)
47 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
48 started := time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC)
49
50 store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
51 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{
52 App: "blog", Revision: "rev-1", Status: "active",
53 }, true, nil).Once()
54 store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
55 App: "blog", Converged: true, ConvergedRevision: "rev-1",
56 Services: map[string]domain.AppEffectiveService{
57 "web": {EffectiveRevision: "rev-1", Digest: "sha256:abc", Container: "ctr-1"},
58 },
59 }, true, nil).Once()
60 store.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
61 store.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{
62 App: "blog", ID: "app-1",
63 Volumes: []domain.AppOwnedVolume{
64 {Name: "data", Service: "web", RuntimeName: "gordon-blog--web--vol--data", State: domain.AppResourceAttached},
65 },
66 Secrets: []domain.AppOwnedSecret{
67 {Service: "web", Env: "DATABASE_URL", Name: "database-url", Path: "gordon/apps/app-1/blog/web/database-url", State: domain.AppResourceAttached},
68 },
69 Images: []domain.AppOwnedImage{
70 {Service: "web", Reference: "registry.example.com/blog/web:1.4.2", State: domain.AppResourceAttached},
71 },
72 Services: map[string]domain.AppServiceRecovery{"web": {RestartUnsafe: true}},
73 }, nil).Once()
74 store.EXPECT().LoadLatestOperation(mock.Anything, "blog").Return(domain.AppOperation{
75 Op: "op-9", Kind: "deploy", App: "blog", Outcome: domain.AppOutcomeSuccess, StartedAt: started,
76 }, true, nil).Once()
77
78 detail, err := svc.Show(ctx, "blog")
79 require.NoError(t, err)
80 assert.False(t, detail.Pending, "desired == ACTIVE is not pending")
81 assert.True(t, detail.Converged)
82 assert.Equal(t, "rev-1", detail.ConvergedRevision)
83 require.Contains(t, detail.Services, "web")
84 assert.Equal(t, "ctr-1", detail.Services["web"].Container)
85 assert.Equal(t, "sha256:abc", detail.Services["web"].Digest)
86 assert.True(t, detail.Services["web"].RestartUnsafe)
87 assert.Equal(t, []string{"gordon-blog--web--vol--data"}, detail.Retained.Volumes)
88 assert.Equal(t, []string{"gordon/apps/app-1/blog/web/database-url"}, detail.Retained.Secrets)
89 assert.Equal(t, []string{"registry.example.com/blog/web:1.4.2"}, detail.Retained.Images)
90 assert.Equal(t, "op-9", detail.LastOp)
91 assert.Equal(t, "deploy", detail.LastOpKind)
92 assert.Equal(t, domain.AppOutcomeSuccess, detail.LastOutcome)
93 assert.Equal(t, started, detail.LastOpStartedAt)
94}
95
96func TestAppService_Show_MixedRevisionsArePending(t *testing.T) {
97 ctx := context.Background()
98 store := newMockAppState(t)
99 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
100
101 store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
102 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{
103 App: "blog", Revision: "rev-2", Status: "pending",
104 }, true, nil).Once()
105 store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
106 App: "blog", Converged: true, ConvergedRevision: "rev-1",
107 Services: map[string]domain.AppEffectiveService{
108 "web": {EffectiveRevision: "rev-1", Container: "ctr-1"},
109 },
110 }, true, nil).Once()
111 store.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
112 store.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
113 store.EXPECT().LoadLatestOperation(mock.Anything, "blog").Return(domain.AppOperation{
114 Op: "op-8", Kind: "deploy", App: "blog", Outcome: domain.AppOutcomeFailed,
115 }, true, nil).Once()
116
117 detail, err := svc.Show(ctx, "blog")
118 require.NoError(t, err)
119 assert.True(t, detail.Pending, "a newer desired revision is pending")
120 assert.Equal(t, "rev-1", detail.ConvergedRevision, "ACTIVE stays authoritative")
121 assert.Equal(t, domain.AppOutcomeFailed, detail.LastOutcome, "the failed journal is reported")
122}
123
124func TestAppService_Show_StoppedAppKeepsReadModel(t *testing.T) {
125 ctx := context.Background()
126 store := newMockAppState(t)
127 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
128
129 store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
130 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{
131 App: "blog", Revision: "rev-1", Status: "active",
132 }, true, nil).Once()
133 store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
134 App: "blog", Converged: true, ConvergedRevision: "rev-1",
135 Services: map[string]domain.AppEffectiveService{"web": {EffectiveRevision: "rev-1", Container: "ctr-1"}},
136 }, true, nil).Once()
137 store.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog", Stopped: true}, nil).Once()
138 store.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
139 store.EXPECT().LoadLatestOperation(mock.Anything, "blog").Return(domain.AppOperation{}, false, nil).Once()
140
141 detail, err := svc.Show(ctx, "blog")
142 require.NoError(t, err)
143 assert.True(t, detail.Stopped)
144 assert.False(t, detail.Pending, "stopping does not make desired state pending")
145 require.Contains(t, detail.Services, "web")
146}
147
148// TestAppService_Show_UnknownAppIsNotFound proves a name with no live app
149// identity reports not-found instead of an empty detail.
150func TestAppService_Show_UnknownAppIsNotFound(t *testing.T) {
151 ctx := context.Background()
152 store := newMockAppState(t)
153 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
154
155 store.EXPECT().AppExists(mock.Anything, "ghost").Return(false, nil).Once()
156
157 _, err := svc.Show(ctx, "ghost")
158 require.ErrorIs(t, err, domain.ErrAppNotFound)
159}
160
161func TestAppService_List_SkipsRetiredNamesAndReportsReadModel(t *testing.T) {
162 ctx := context.Background()
163 store := newMockAppState(t)
164 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
165
166 store.EXPECT().ListApps(mock.Anything).Return([]string{"blog", "gone"}, nil).Once()
167 // blog is live: desired rev-2 while ACTIVE holds rev-1, latest deploy partial.
168 store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
169 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{
170 App: "blog", Revision: "rev-2", Status: "pending",
171 }, true, nil).Once()
172 store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
173 App: "blog", Converged: true, ConvergedRevision: "rev-1",
174 }, true, nil).Once()
175 store.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
176 store.EXPECT().LoadLatestOperation(mock.Anything, "blog").Return(domain.AppOperation{
177 Op: "op-7", Kind: "deploy", App: "blog", Outcome: domain.AppOutcomePartial,
178 }, true, nil).Once()
179 // gone keeps only its retired journal and is not an app any more.
180 store.EXPECT().AppExists(mock.Anything, "gone").Return(false, nil).Once()
181
182 summaries, err := svc.List(ctx)
183 require.NoError(t, err)
184 require.Len(t, summaries, 1)
185 assert.Equal(t, "blog", summaries[0].App)
186 assert.Equal(t, "rev-2", summaries[0].Desired)
187 assert.Equal(t, "pending", summaries[0].DesiredStatus)
188 assert.Equal(t, "rev-1", summaries[0].Active)
189 assert.True(t, summaries[0].Pending)
190 assert.Equal(t, domain.AppOutcomePartial, summaries[0].LastOutcome)
191}