v3.3.1 31f1eec

app_read_model_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close