Skip to content

Commit 4298fc5

Browse files
committed
fix: harden onboarding completion and state invariants
1 parent 569b6ac commit 4298fc5

21 files changed

Lines changed: 266 additions & 56 deletions

‎internal/app/chat/empty_state_poll_test.go‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,3 @@ func TestEmptyStateSummaryMessageUpdatesState(t *testing.T) {
7878
t.Fatalf("unexpected service count: got %d want %d", m.policySummary.ServiceCount, 5)
7979
}
8080
}
81-

‎internal/app/onboarding/auth/authenticate_test.go‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ func TestAuthPollInterval(t *testing.T) {
2121
}
2222

2323
for _, tc := range tests {
24-
tc := tc
2524
t.Run(tc.name, func(t *testing.T) {
2625
t.Parallel()
2726
got := authPollInterval(tc.interval)

‎internal/app/onboarding/datadog/appkey_test.go‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ func TestAppKeyErrorMessage(t *testing.T) {
4141
}
4242

4343
for _, tc := range tests {
44-
tc := tc
4544
t.Run(tc.name, func(t *testing.T) {
4645
t.Parallel()
4746
got := appKeyErrorMessage(tc.err)

‎internal/app/onboarding/datadog/discovery_test.go‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,3 @@ func TestDiscoveryStatusReadyCompletesStep(t *testing.T) {
8282
t.Fatalf("expected DatadogDiscoveryComplete message")
8383
}
8484
}
85-

‎internal/app/onboarding/errorfmt/errorfmt_test.go‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ func TestUserFacing(t *testing.T) {
4343
}
4444

4545
for _, tc := range tests {
46-
tc := tc
4746
t.Run(tc.name, func(t *testing.T) {
4847
t.Parallel()
4948
got := UserFacing(tc.err, tc.fallback)

‎internal/app/onboarding/gate_requirements.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ func (m *Model) rewindGateFor(target Gate) Gate {
1010
return target
1111
}
1212
rewind := bootstrap.RewindGate(
13-
bootstrap.Gate(target),
13+
target,
1414
def.requirement,
1515
bootstrap.State{
1616
User: m.state.user,
@@ -22,5 +22,5 @@ func (m *Model) rewindGateFor(target Gate) Gate {
2222
DDAccount: m.state.ddAccount,
2323
},
2424
)
25-
return Gate(rewind)
25+
return rewind
2626
}

‎internal/app/onboarding/gate_requirements_test.go‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ func TestRewindGateFor(t *testing.T) {
2222
}
2323

2424
for _, tc := range tests {
25-
tc := tc
2625
t.Run(tc.name, func(t *testing.T) {
2726
t.Parallel()
2827
m := newTestModel(t)

‎internal/app/onboarding/transition_handlers.go‎

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/usetero/cli/internal/app/onboarding/msgs"
99
"github.com/usetero/cli/internal/core/bootstrap"
10+
"github.com/usetero/cli/internal/domain"
1011
)
1112

1213
func (m *Model) toCoreState() bootstrap.State {
@@ -29,6 +30,15 @@ func (m *Model) applyCoreState(state bootstrap.State) {
2930
m.state.ddSite = state.DDSite
3031
m.state.ddAPIKey = state.DDAPIKey
3132
m.state.ddAccount = state.DDAccount
33+
m.syncServicesToState()
34+
}
35+
36+
func (m *Model) syncServicesToState() {
37+
accountID := domain.AccountID("")
38+
if m.state.account != nil {
39+
accountID = m.state.account.ID
40+
}
41+
m.services = m.services.WithAccountID(accountID)
3242
}
3343

3444
func (m *Model) handlePreflightResolved(msg msgs.PreflightResolved) TransitionOutcome {
@@ -56,14 +66,8 @@ func (m *Model) handlePreflightResolved(msg msgs.PreflightResolved) TransitionOu
5666
Account: msg.State.Account,
5767
},
5868
)
59-
nextGate := Gate(next)
69+
nextGate := next
6070
m.applyCoreState(nextState)
61-
if m.state.org != nil && m.state.account == nil {
62-
m.services = m.services.WithAccountID("")
63-
}
64-
if m.state.account != nil {
65-
m.services = m.services.WithAccountID(m.state.account.ID)
66-
}
6771
m.scope.Info("preflight decision",
6872
slog.String("outcome", string(msg.State.Outcome)),
6973
slog.String("next_gate", nextGate.String()))
@@ -74,129 +78,137 @@ func (m *Model) handleAuthenticated(msg msgs.Authenticated) TransitionOutcome {
7478
m.scope.Info("user authenticated", "user_id", msg.User.ID)
7579
nextState, next := bootstrap.ApplyAuthenticated(m.toCoreState(), msg.User)
7680
m.applyCoreState(nextState)
77-
return advance(Gate(next))
81+
return advance(next)
7882
}
7983

8084
func (m *Model) handleRoleSelected(msg msgs.RoleSelected) TransitionOutcome {
8185
m.scope.Info("role selected", slog.String("role", msg.Role))
8286
nextState, next := bootstrap.ApplyRoleSelected(m.toCoreState(), msg.Role)
8387
m.applyCoreState(nextState)
84-
return advance(Gate(next))
88+
return advance(next)
8589
}
8690

8791
func (m *Model) handleOrgSelected(msg msgs.OrgSelected) TransitionOutcome {
8892
m.scope.Info("organization selected", slog.String("org_id", msg.Org.ID.String()))
8993
nextState, next := bootstrap.ApplyOrgSelected(m.toCoreState(), msg.Org)
9094
m.applyCoreState(nextState)
91-
m.services = m.services.WithAccountID("")
92-
return advance(Gate(next))
95+
return advance(next)
9396
}
9497

9598
func (m *Model) handleNoOrgs() TransitionOutcome {
9699
m.scope.Debug("no organizations found")
97100
nextState, next := bootstrap.ApplyNoOrgs(m.toCoreState())
98101
m.applyCoreState(nextState)
99-
return advance(Gate(next))
102+
return advance(next)
100103
}
101104

102105
func (m *Model) handleOrgCreated(msg msgs.OrgCreated) TransitionOutcome {
103106
m.scope.Info("organization created", slog.String("org_id", msg.Org.ID.String()))
104107
nextState, next := bootstrap.ApplyOrgCreated(m.toCoreState(), msg.Org)
105108
m.applyCoreState(nextState)
106-
m.services = m.services.WithAccountID("")
107-
return advance(Gate(next))
109+
return advance(next)
108110
}
109111

110112
func (m *Model) handleAccountSelected(msg msgs.AccountSelected) TransitionOutcome {
111113
m.scope.Info("account selected", slog.String("account_id", msg.Account.ID.String()))
112114
nextState, next := bootstrap.ApplyAccountSelected(m.toCoreState(), msg.Org, msg.Account)
113115
m.applyCoreState(nextState)
114-
return advance(Gate(next))
116+
return advance(next)
115117
}
116118

117119
func (m *Model) handleNoAccounts(msg msgs.NoAccounts) TransitionOutcome {
118120
m.scope.Debug("no accounts found")
119121
nextState, next := bootstrap.ApplyNoAccounts(m.toCoreState(), msg.Org)
120122
m.applyCoreState(nextState)
121-
return advance(Gate(next))
123+
return advance(next)
122124
}
123125

124126
func (m *Model) handleAccountCreated(msg msgs.AccountCreated) TransitionOutcome {
125127
m.scope.Info("account created", slog.String("account_id", msg.Account.ID.String()))
126128
nextState, next := bootstrap.ApplyAccountCreated(m.toCoreState(), msg.Org, msg.Account)
127129
m.applyCoreState(nextState)
128-
return advance(Gate(next))
130+
return advance(next)
129131
}
130132

131133
func (m *Model) handleRuntimeReady(msg msgs.RuntimeReady) TransitionOutcome {
132134
m.scope.Info("runtime initialized", slog.String("account_id", msg.Account.ID.String()))
133135
nextState, next := bootstrap.ApplyRuntimeReady(m.toCoreState(), msg.Org, msg.Account)
134136
m.applyCoreState(nextState)
135-
m.services = m.services.WithAccountID(msg.Account.ID)
136-
return advance(Gate(next))
137+
return advance(next)
137138
}
138139

139140
func (m *Model) handleDatadogReady() TransitionOutcome {
140141
m.scope.Debug("datadog ready")
141142
nextState, next := bootstrap.ApplyDatadogReady(m.toCoreState())
142143
m.applyCoreState(nextState)
143-
return advance(Gate(next))
144+
return advance(next)
144145
}
145146

146147
func (m *Model) handleDatadogNeeded() TransitionOutcome {
147148
m.scope.Debug("datadog setup needed")
148149
nextState, next := bootstrap.ApplyDatadogNeeded(m.toCoreState())
149150
m.applyCoreState(nextState)
150-
return advance(Gate(next))
151+
return advance(next)
151152
}
152153

153154
func (m *Model) handleDatadogRegionSelected(msg msgs.DatadogRegionSelected) TransitionOutcome {
154155
m.scope.Info("datadog region selected", slog.String("site", string(msg.Site)))
155156
nextState, next := bootstrap.ApplyDatadogRegionSelected(m.toCoreState(), msg.Site)
156157
m.applyCoreState(nextState)
157-
return advance(Gate(next))
158+
return advance(next)
158159
}
159160

160161
func (m *Model) handleDatadogAPIKeyEntered(msg msgs.DatadogAPIKeyEntered) TransitionOutcome {
161162
m.scope.Debug("datadog api key validated")
162163
nextState, next := bootstrap.ApplyDatadogAPIKeyEntered(m.toCoreState(), msg.APIKey)
163164
m.applyCoreState(nextState)
164-
return advance(Gate(next))
165+
return advance(next)
165166
}
166167

167168
func (m *Model) handleDatadogAccountCreated(msg msgs.DatadogAccountCreated) TransitionOutcome {
168169
m.scope.Info("datadog account created", slog.String("datadog_account_id", msg.DatadogAccountID.String()))
169170
nextState, next := bootstrap.ApplyDatadogAccountCreated(m.toCoreState(), msg.DatadogAccountID)
170171
m.applyCoreState(nextState)
171-
return advance(Gate(next))
172+
return advance(next)
172173
}
173174

174175
func (m *Model) handleDatadogDiscoveryComplete() TransitionOutcome {
175176
m.scope.Info("datadog discovery complete")
176177
nextState, next := bootstrap.ApplyDatadogDiscoveryComplete(m.toCoreState())
177178
m.applyCoreState(nextState)
178-
return advance(Gate(next))
179+
return advance(next)
179180
}
180181

181182
func (m *Model) handleWorkspaceSelected(msg msgs.WorkspaceSelected) TransitionOutcome {
182183
m.scope.Info("workspace selected", slog.String("workspace_id", string(msg.Workspace.ID)))
183184
nextState, next := bootstrap.ApplyWorkspaceSelected(m.toCoreState(), msg.Workspace)
184185
m.applyCoreState(nextState)
185-
return advance(Gate(next))
186+
return advance(next)
186187
}
187188

188189
func (m *Model) handleSyncComplete() TransitionOutcome {
190+
completion, ok := bootstrap.CompleteOnboarding(m.toCoreState())
191+
if !ok {
192+
m.scope.Error("sync completed without required onboarding state",
193+
slog.Bool("has_user", m.state.user != nil),
194+
slog.Bool("has_org", m.state.org != nil),
195+
slog.Bool("has_account", m.state.account != nil),
196+
slog.Bool("has_workspace", m.state.workspace != nil),
197+
)
198+
return noop()
199+
}
200+
189201
m.scope.Info("onboarding complete",
190-
slog.String("org_id", m.state.org.ID.String()),
191-
slog.String("account_id", m.state.account.ID.String()),
192-
slog.String("workspace_id", string(m.state.workspace.ID)),
202+
slog.String("org_id", completion.Org.ID.String()),
203+
slog.String("account_id", completion.Account.ID.String()),
204+
slog.String("workspace_id", string(completion.Workspace.ID)),
193205
)
194206
return advanceWith("", func() tea.Msg {
195207
return msgs.OnboardingComplete{
196-
User: m.state.user,
197-
Org: *m.state.org,
198-
Account: *m.state.account,
199-
Workspace: *m.state.workspace,
208+
User: completion.User,
209+
Org: completion.Org,
210+
Account: completion.Account,
211+
Workspace: completion.Workspace,
200212
}
201213
})
202214
}

‎internal/app/onboarding/transitions_test.go‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ func TestHandleTransitionPreflightRouting(t *testing.T) {
7979
}
8080

8181
for _, tc := range tests {
82-
tc := tc
8382
t.Run(tc.name, func(t *testing.T) {
8483
t.Parallel()
8584
m := newTestModel(t)
@@ -114,7 +113,6 @@ func TestHandleTransitionDatadogBranchRouting(t *testing.T) {
114113
}
115114

116115
for _, tc := range tests {
117-
tc := tc
118116
t.Run(tc.name, func(t *testing.T) {
119117
t.Parallel()
120118
m := newTestModel(t)
@@ -189,6 +187,40 @@ func TestHandleTransitionRuntimeReady(t *testing.T) {
189187
}
190188
}
191189

190+
func TestHandleTransitionRuntimeReadySyncsServiceAccountScope(t *testing.T) {
191+
t.Parallel()
192+
193+
m, client := newTestModelWithClient(t)
194+
var scopedAccountID domain.AccountID
195+
client.SetAccountIDFunc = func(accountID domain.AccountID) {
196+
scopedAccountID = accountID
197+
}
198+
199+
org := ptrOrg("org-1")
200+
account := ptrAccount("acc-1")
201+
202+
_ = m.handleTransition(msgs.RuntimeReady{Org: *org, Account: *account})
203+
if scopedAccountID != "acc-1" {
204+
t.Fatalf("scoped account id = %q, want %q", scopedAccountID, "acc-1")
205+
}
206+
}
207+
208+
func TestHandleTransitionOrgSelectedClearsServiceAccountScope(t *testing.T) {
209+
t.Parallel()
210+
211+
m, client := newTestModelWithClient(t)
212+
var scopedAccountID domain.AccountID
213+
client.SetAccountIDFunc = func(accountID domain.AccountID) {
214+
scopedAccountID = accountID
215+
}
216+
217+
m.state.account = ptrAccount("acc-1")
218+
_ = m.handleTransition(msgs.OrgSelected{Org: *ptrOrg("org-2")})
219+
if scopedAccountID != "" {
220+
t.Fatalf("scoped account id = %q, want empty", scopedAccountID)
221+
}
222+
}
223+
192224
func TestHandleTransitionSyncComplete(t *testing.T) {
193225
t.Parallel()
194226

@@ -212,8 +244,28 @@ func TestHandleTransitionSyncComplete(t *testing.T) {
212244
}
213245
}
214246

247+
func TestHandleTransitionSyncCompleteMissingStateNoops(t *testing.T) {
248+
t.Parallel()
249+
250+
m := newTestModel(t)
251+
m.state.user = ptrUser("user-1")
252+
m.state.org = ptrOrg("org-1")
253+
// Missing account/workspace should not panic or emit completion payload.
254+
255+
cmd := m.handleTransition(msgs.SyncComplete{})
256+
if cmd != nil {
257+
t.Fatal("expected nil command when completion state is incomplete")
258+
}
259+
}
260+
215261
func newTestModel(t *testing.T) *Model {
216262
t.Helper()
263+
m, _ := newTestModelWithClient(t)
264+
return m
265+
}
266+
267+
func newTestModelWithClient(t *testing.T) (*Model, *apitest.MockClient) {
268+
t.Helper()
217269

218270
scope := logtest.NewScope(t)
219271
client := apitest.NewMockClient()
@@ -225,7 +277,7 @@ func newTestModel(t *testing.T) *Model {
225277

226278
m := New(context.Background(), styles.NewTheme(true), services, userPrefs, orgPrefs, authSvc, syncer, scope)
227279
m.SetSize(120, 40)
228-
return m
280+
return m, client
229281
}
230282

231283
func ptrOrg(id string) *domain.Organization {

‎internal/app/onboarding/visibility_test.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,11 @@ type fixedTestStep struct {
6868
view string
6969
}
7070

71-
func (s fixedTestStep) Init() tea.Cmd { return nil }
72-
func (s fixedTestStep) Update(msg tea.Msg) tea.Cmd { return nil }
73-
func (s fixedTestStep) View() string { return s.view }
74-
func (s fixedTestStep) SetSize(width, height int) {}
75-
func (s fixedTestStep) ShortHelp() []key.Binding { return nil }
71+
func (s fixedTestStep) Init() tea.Cmd { return nil }
72+
func (s fixedTestStep) Update(msg tea.Msg) tea.Cmd { return nil }
73+
func (s fixedTestStep) View() string { return s.view }
74+
func (s fixedTestStep) SetSize(width, height int) {}
75+
func (s fixedTestStep) ShortHelp() []key.Binding { return nil }
7676

7777
type hiddenTestStep struct {
7878
fixedTestStep

0 commit comments

Comments
 (0)