Skip to content

Commit 568b43e

Browse files
test(deploy): wake handler happy-path + error-branch coverage (#54)
sqlmock-driven coverage for the flag-ON Wake branches (happy path scale+flip+ re-read, not-found, cross-team 404, scale-failure 503, DB-flip 503) so the 100%-patch gate is satisfied on deploy_wake.go's handler body. The flag-off 501-inert path stays in deploy_wake_test.go. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d940d07 commit 568b43e

1 file changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package handlers
2+
3+
// deploy_wake_mock_test.go — sqlmock-driven happy-path + error-branch coverage
4+
// for POST /deploy/:id/wake (Wake handler, Task #54). The flag-off 501 path is
5+
// covered in deploy_wake_test.go; this file covers the flag-ON branches:
6+
// happy path (scale + DB flip + re-read), not-found, cross-team 404, scale
7+
// failure (503), and the WakeDeployment DB-error (503).
8+
//
9+
// In-package test so the unexported DeployHandler fields are reachable and a
10+
// recording compute provider can be injected without import indirection.
11+
12+
import (
13+
"context"
14+
"database/sql"
15+
"errors"
16+
"io"
17+
"net/http"
18+
"net/http/httptest"
19+
"testing"
20+
"time"
21+
22+
sqlmock "github.com/DATA-DOG/go-sqlmock"
23+
"github.com/alicebob/miniredis/v2"
24+
"github.com/gofiber/fiber/v2"
25+
"github.com/google/uuid"
26+
"github.com/redis/go-redis/v9"
27+
"github.com/stretchr/testify/require"
28+
29+
"instant.dev/internal/config"
30+
"instant.dev/internal/middleware"
31+
"instant.dev/internal/plans"
32+
"instant.dev/internal/providers/compute"
33+
)
34+
35+
// wakeRecordingProvider records Scale calls and can be told to fail.
36+
type wakeRecordingProvider struct {
37+
scaleCalls []int32
38+
scaleErr error
39+
}
40+
41+
func (p *wakeRecordingProvider) Deploy(context.Context, compute.DeployOptions) (*compute.AppDeployment, error) {
42+
return nil, nil
43+
}
44+
func (p *wakeRecordingProvider) Status(context.Context, string) (*compute.AppDeployment, error) {
45+
return nil, nil
46+
}
47+
func (p *wakeRecordingProvider) Logs(context.Context, string, bool) (io.ReadCloser, error) {
48+
return nil, nil
49+
}
50+
func (p *wakeRecordingProvider) Teardown(context.Context, string) error { return nil }
51+
func (p *wakeRecordingProvider) Redeploy(context.Context, string, []byte, map[string]string) (*compute.AppDeployment, error) {
52+
return nil, nil
53+
}
54+
func (p *wakeRecordingProvider) UpdateAccessControl(context.Context, string, bool, []string) error {
55+
return nil
56+
}
57+
func (p *wakeRecordingProvider) Scale(_ context.Context, _ string, replicas int32) error {
58+
p.scaleCalls = append(p.scaleCalls, replicas)
59+
return p.scaleErr
60+
}
61+
62+
// wakeMockApp builds a flag-ON wake app with faked auth Locals + the recording
63+
// provider. Returns app + teamID + provider so tests assert Scale calls.
64+
func wakeMockApp(t *testing.T, db *sql.DB, prov compute.Provider) (*fiber.App, uuid.UUID) {
65+
t.Helper()
66+
teamID := uuid.New()
67+
68+
mr, err := miniredis.Run()
69+
require.NoError(t, err)
70+
t.Cleanup(mr.Close)
71+
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
72+
t.Cleanup(func() { _ = rdb.Close() })
73+
74+
h := &DeployHandler{
75+
db: db,
76+
rdb: rdb,
77+
cfg: &config.Config{DeployScaleToZeroEnabled: true, Environment: "test"},
78+
compute: prov,
79+
planRegistry: plans.Default(),
80+
}
81+
app := fiber.New(fiber.Config{
82+
ErrorHandler: func(c *fiber.Ctx, err error) error {
83+
if errors.Is(err, ErrResponseWritten) {
84+
return nil
85+
}
86+
code := fiber.StatusInternalServerError
87+
if e, ok := err.(*fiber.Error); ok {
88+
code = e.Code
89+
}
90+
return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error"})
91+
},
92+
})
93+
app.Use(func(c *fiber.Ctx) error {
94+
c.Locals(middleware.LocalKeyTeamID, teamID.String())
95+
return c.Next()
96+
})
97+
app.Post("/deploy/:id/wake", h.Wake)
98+
return app, teamID
99+
}
100+
101+
// wakeDeploymentRow builds the full deploymentColumns row for sqlmock. Column
102+
// order MUST match the deploymentColumns constant in models/deployment.go.
103+
func wakeDeploymentRow(id, teamID uuid.UUID, appID, providerID string, scaledToZero bool) *sqlmock.Rows {
104+
cols := []string{
105+
"id", "team_id", "resource_id", "app_id", "provider_id", "status", "app_url",
106+
"env_vars", "port", "tier", "env", "private", "allowed_ips", "error_message",
107+
"created_at", "updated_at",
108+
"notify_webhook", "notify_webhook_secret", "notify_state", "notify_attempts",
109+
"expires_at", "ttl_policy", "reminders_sent", "last_reminder_at",
110+
"source", "image_ref", "registry_creds_enc",
111+
"git_url", "git_ref", "git_token_enc",
112+
"last_activity_at", "scaled_to_zero", "always_on",
113+
}
114+
now := time.Now()
115+
return sqlmock.NewRows(cols).AddRow(
116+
id, teamID, uuid.NullUUID{}, appID, providerID, "healthy", "https://x.deployment.instanode.dev",
117+
[]byte(`{}`), 8080, "hobby", "production", false, "", "",
118+
now, now,
119+
sql.NullString{}, sql.NullString{}, "unset", 0,
120+
sql.NullTime{}, "permanent", 0, sql.NullTime{},
121+
"tarball", "", "",
122+
"", "", "",
123+
sql.NullTime{Time: now, Valid: true}, scaledToZero, false,
124+
)
125+
}
126+
127+
func TestWake_HappyPath(t *testing.T) {
128+
db, mock, err := sqlmock.New()
129+
require.NoError(t, err)
130+
defer db.Close()
131+
132+
prov := &wakeRecordingProvider{}
133+
app, teamID := wakeMockApp(t, db, prov)
134+
id := uuid.New()
135+
136+
expectTeamLookupOK(mock, teamID, "hobby")
137+
// GetDeploymentByAppID — asleep row owned by the team.
138+
mock.ExpectQuery(`FROM deployments WHERE app_id = \$1`).
139+
WithArgs("app-abc").
140+
WillReturnRows(wakeDeploymentRow(id, teamID, "app-abc", "app-abc", true))
141+
// WakeDeployment UPDATE.
142+
mock.ExpectExec(`UPDATE deployments`).
143+
WithArgs(id).
144+
WillReturnResult(sqlmock.NewResult(0, 1))
145+
// Re-read after wake.
146+
mock.ExpectQuery(`FROM deployments WHERE id = \$1`).
147+
WithArgs(id).
148+
WillReturnRows(wakeDeploymentRow(id, teamID, "app-abc", "app-abc", false))
149+
150+
req := httptest.NewRequest(http.MethodPost, "/deploy/app-abc/wake", nil)
151+
resp, err := app.Test(req, 2000)
152+
require.NoError(t, err)
153+
defer resp.Body.Close()
154+
155+
if resp.StatusCode != http.StatusOK {
156+
t.Fatalf("wake status = %d, want 200", resp.StatusCode)
157+
}
158+
if len(prov.scaleCalls) != 1 || prov.scaleCalls[0] != 1 {
159+
t.Errorf("expected one Scale(1) call, got %v", prov.scaleCalls)
160+
}
161+
require.NoError(t, mock.ExpectationsWereMet())
162+
}
163+
164+
func TestWake_NotFound(t *testing.T) {
165+
db, mock, err := sqlmock.New()
166+
require.NoError(t, err)
167+
defer db.Close()
168+
169+
app, teamID := wakeMockApp(t, db, &wakeRecordingProvider{})
170+
expectTeamLookupOK(mock, teamID, "hobby")
171+
mock.ExpectQuery(`FROM deployments WHERE app_id = \$1`).
172+
WithArgs("app-missing").
173+
WillReturnError(sql.ErrNoRows)
174+
175+
req := httptest.NewRequest(http.MethodPost, "/deploy/app-missing/wake", nil)
176+
resp, err := app.Test(req, 2000)
177+
require.NoError(t, err)
178+
defer resp.Body.Close()
179+
if resp.StatusCode != http.StatusNotFound {
180+
t.Fatalf("wake on missing deploy = %d, want 404", resp.StatusCode)
181+
}
182+
}
183+
184+
func TestWake_CrossTeam404(t *testing.T) {
185+
db, mock, err := sqlmock.New()
186+
require.NoError(t, err)
187+
defer db.Close()
188+
189+
app, teamID := wakeMockApp(t, db, &wakeRecordingProvider{})
190+
otherTeam := uuid.New()
191+
id := uuid.New()
192+
expectTeamLookupOK(mock, teamID, "hobby")
193+
// Row owned by a DIFFERENT team → handler must 404 (not 403).
194+
mock.ExpectQuery(`FROM deployments WHERE app_id = \$1`).
195+
WithArgs("app-other").
196+
WillReturnRows(wakeDeploymentRow(id, otherTeam, "app-other", "app-other", true))
197+
198+
req := httptest.NewRequest(http.MethodPost, "/deploy/app-other/wake", nil)
199+
resp, err := app.Test(req, 2000)
200+
require.NoError(t, err)
201+
defer resp.Body.Close()
202+
if resp.StatusCode != http.StatusNotFound {
203+
t.Fatalf("cross-team wake = %d, want 404", resp.StatusCode)
204+
}
205+
}
206+
207+
func TestWake_ScaleFailure503(t *testing.T) {
208+
db, mock, err := sqlmock.New()
209+
require.NoError(t, err)
210+
defer db.Close()
211+
212+
prov := &wakeRecordingProvider{scaleErr: errors.New("k8s boom")}
213+
app, teamID := wakeMockApp(t, db, prov)
214+
id := uuid.New()
215+
expectTeamLookupOK(mock, teamID, "hobby")
216+
mock.ExpectQuery(`FROM deployments WHERE app_id = \$1`).
217+
WithArgs("app-boom").
218+
WillReturnRows(wakeDeploymentRow(id, teamID, "app-boom", "app-boom", true))
219+
220+
req := httptest.NewRequest(http.MethodPost, "/deploy/app-boom/wake", nil)
221+
resp, err := app.Test(req, 2000)
222+
require.NoError(t, err)
223+
defer resp.Body.Close()
224+
if resp.StatusCode != http.StatusServiceUnavailable {
225+
t.Fatalf("scale-failure wake = %d, want 503", resp.StatusCode)
226+
}
227+
}
228+
229+
func TestWake_DBFlipFailure503(t *testing.T) {
230+
db, mock, err := sqlmock.New()
231+
require.NoError(t, err)
232+
defer db.Close()
233+
234+
app, teamID := wakeMockApp(t, db, &wakeRecordingProvider{})
235+
id := uuid.New()
236+
expectTeamLookupOK(mock, teamID, "hobby")
237+
mock.ExpectQuery(`FROM deployments WHERE app_id = \$1`).
238+
WithArgs("app-dbfail").
239+
WillReturnRows(wakeDeploymentRow(id, teamID, "app-dbfail", "app-dbfail", true))
240+
mock.ExpectExec(`UPDATE deployments`).
241+
WithArgs(id).
242+
WillReturnError(errors.New("db exploded"))
243+
244+
req := httptest.NewRequest(http.MethodPost, "/deploy/app-dbfail/wake", nil)
245+
resp, err := app.Test(req, 2000)
246+
require.NoError(t, err)
247+
defer resp.Body.Close()
248+
if resp.StatusCode != http.StatusServiceUnavailable {
249+
t.Fatalf("db-flip-failure wake = %d, want 503", resp.StatusCode)
250+
}
251+
}

0 commit comments

Comments
 (0)