Skip to content

Commit b0ac2ba

Browse files
test(scale-to-zero): cover the 6 patch-coverage-flagged changed lines
The patch-coverage gate (100% of changed lines) flagged 6 uncovered lines added by the scale-to-zero feature commits: - config.go 517-518: the DEPLOY_SCALE_TO_ZERO_ENABLED=true branch — add TestLoad_DeployScaleToZeroEnabled (truthy + falsy table, mirrors the DeploySourceGitEnabled test) and register the key in allKeys(). - deploy_wake.go 57-58 / 67 / 101-105: the requireTeam-error arm, the generic GetDeploymentByAppID driver-error (503 fetch_failed) arm, and the post-write re-read-failure fallback (scale+DB already succeeded → 200, not 5xx). Add TestWake_RequireTeamFails, TestWake_FetchDriverError503, TestWake_ReReadFailureFallsBack + a no-auth app helper. - deployment.go 591-592 / 610-611 / 626-627: the fmt.Errorf error returns of MarkDeploymentScaledToZero / WakeDeployment / SetDeploymentAlwaysOn. Add sqlmock-driven *_DriverError tests (the happy + CAS/RowsAffected paths are already covered by the real-DB tests). Test-only; no production code change. Flag remains default-OFF. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c65b211 commit b0ac2ba

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

internal/config/config_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ func allKeys() []string {
6363
"METRICS_TOKEN", "DASHBOARD_BASE_URL", "API_PUBLIC_URL",
6464
"DELETION_CONFIRMATION_TTL_MINUTES", "FAMILY_BINDINGS_ENABLED",
6565
"DEPLOY_SOURCE_IMAGE_ENABLED", "DEPLOY_SOURCE_GIT_ENABLED",
66+
"DEPLOY_SCALE_TO_ZERO_ENABLED",
6667
"GITHUB_APP_ENABLED", "GITHUB_APP_ID", "GITHUB_APP_SLUG", "GITHUB_APP_PRIVATE_KEY",
6768
"GITHUB_APP_WEBHOOK_SECRET", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET",
6869
"BREVO_WEBHOOK_SECRET", "SES_SNS_SUBSCRIPTION_ARN",
@@ -387,6 +388,21 @@ func TestLoad_DeploySourceGitEnabled(t *testing.T) {
387388
}
388389
}
389390

391+
func TestLoad_DeployScaleToZeroEnabled(t *testing.T) {
392+
for _, val := range []string{"true", "1", "yes", "TRUE", " Yes "} {
393+
applyBaselineEnv(t, map[string]string{"DEPLOY_SCALE_TO_ZERO_ENABLED": val})
394+
if !Load().DeployScaleToZeroEnabled {
395+
t.Errorf("DEPLOY_SCALE_TO_ZERO_ENABLED=%q should enable", val)
396+
}
397+
}
398+
for _, val := range []string{"false", "0", "no", "maybe", ""} {
399+
applyBaselineEnv(t, map[string]string{"DEPLOY_SCALE_TO_ZERO_ENABLED": val})
400+
if Load().DeployScaleToZeroEnabled {
401+
t.Errorf("DEPLOY_SCALE_TO_ZERO_ENABLED=%q should stay disabled", val)
402+
}
403+
}
404+
}
405+
390406
func TestLoad_GitHubAppEnabled(t *testing.T) {
391407
// When enabling the App, Load() fails closed unless the webhook secret +
392408
// private key + app id are present (review HIGH-1), so set them here.

internal/handlers/deploy_wake_mock_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,118 @@ func wakeDeploymentRow(id, teamID uuid.UUID, appID, providerID string, scaledToZ
124124
)
125125
}
126126

127+
// wakeMockAppNoAuth is wakeMockApp without the team-injecting middleware, so
128+
// requireTeam sees an empty team_id and the handler returns 401. Used to cover
129+
// the `team, err := h.requireTeam(c); if err != nil { return err }` arm.
130+
func wakeMockAppNoAuth(t *testing.T, db *sql.DB) *fiber.App {
131+
t.Helper()
132+
mr, err := miniredis.Run()
133+
require.NoError(t, err)
134+
t.Cleanup(mr.Close)
135+
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
136+
t.Cleanup(func() { _ = rdb.Close() })
137+
138+
h := &DeployHandler{
139+
db: db,
140+
rdb: rdb,
141+
cfg: &config.Config{DeployScaleToZeroEnabled: true, Environment: "test"},
142+
compute: &wakeRecordingProvider{},
143+
planRegistry: plans.Default(),
144+
}
145+
app := fiber.New(fiber.Config{
146+
ErrorHandler: func(c *fiber.Ctx, err error) error {
147+
if errors.Is(err, ErrResponseWritten) {
148+
return nil
149+
}
150+
code := fiber.StatusInternalServerError
151+
if e, ok := err.(*fiber.Error); ok {
152+
code = e.Code
153+
}
154+
return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error"})
155+
},
156+
})
157+
app.Post("/deploy/:id/wake", h.Wake)
158+
return app
159+
}
160+
161+
// TestWake_RequireTeamFails covers the requireTeam error arm: no team_id in
162+
// Locals → 401 before any scale or DB work.
163+
func TestWake_RequireTeamFails(t *testing.T) {
164+
db, _, err := sqlmock.New()
165+
require.NoError(t, err)
166+
defer db.Close()
167+
168+
app := wakeMockAppNoAuth(t, db)
169+
req := httptest.NewRequest(http.MethodPost, "/deploy/app-noauth/wake", nil)
170+
resp, err := app.Test(req, 2000)
171+
require.NoError(t, err)
172+
defer resp.Body.Close()
173+
if resp.StatusCode != http.StatusUnauthorized {
174+
t.Fatalf("no-auth wake = %d, want 401", resp.StatusCode)
175+
}
176+
}
177+
178+
// TestWake_FetchDriverError503 covers the generic GetDeploymentByAppID driver
179+
// error arm (NOT sql.ErrNoRows) → 503 fetch_failed.
180+
func TestWake_FetchDriverError503(t *testing.T) {
181+
db, mock, err := sqlmock.New()
182+
require.NoError(t, err)
183+
defer db.Close()
184+
185+
app, teamID := wakeMockApp(t, db, &wakeRecordingProvider{})
186+
expectTeamLookupOK(mock, teamID, "hobby")
187+
mock.ExpectQuery(`FROM deployments WHERE app_id = \$1`).
188+
WithArgs("app-drv").
189+
WillReturnError(errors.New("deployments table exploded"))
190+
191+
req := httptest.NewRequest(http.MethodPost, "/deploy/app-drv/wake", nil)
192+
resp, err := app.Test(req, 2000)
193+
require.NoError(t, err)
194+
defer resp.Body.Close()
195+
if resp.StatusCode != http.StatusServiceUnavailable {
196+
t.Fatalf("fetch-driver-error wake = %d, want 503", resp.StatusCode)
197+
}
198+
}
199+
200+
// TestWake_ReReadFailureFallsBack covers the post-write re-read failure arm:
201+
// scale + WakeDeployment already succeeded, so a failing GetDeploymentByID must
202+
// NOT fail the wake — the handler falls back to the pre-read row with
203+
// ScaledToZero cleared and still returns 200.
204+
func TestWake_ReReadFailureFallsBack(t *testing.T) {
205+
db, mock, err := sqlmock.New()
206+
require.NoError(t, err)
207+
defer db.Close()
208+
209+
prov := &wakeRecordingProvider{}
210+
app, teamID := wakeMockApp(t, db, prov)
211+
id := uuid.New()
212+
213+
expectTeamLookupOK(mock, teamID, "hobby")
214+
mock.ExpectQuery(`FROM deployments WHERE app_id = \$1`).
215+
WithArgs("app-reread").
216+
WillReturnRows(wakeDeploymentRow(id, teamID, "app-reread", "app-reread", true))
217+
mock.ExpectExec(`UPDATE deployments`).
218+
WithArgs(id).
219+
WillReturnResult(sqlmock.NewResult(0, 1))
220+
// Re-read fails → handler must fall back, NOT 5xx.
221+
mock.ExpectQuery(`FROM deployments WHERE id = \$1`).
222+
WithArgs(id).
223+
WillReturnError(errors.New("re-read exploded"))
224+
225+
req := httptest.NewRequest(http.MethodPost, "/deploy/app-reread/wake", nil)
226+
resp, err := app.Test(req, 2000)
227+
require.NoError(t, err)
228+
defer resp.Body.Close()
229+
if resp.StatusCode != http.StatusOK {
230+
body, _ := io.ReadAll(resp.Body)
231+
t.Fatalf("re-read-failure wake = %d, want 200 (fallback); body: %s", resp.StatusCode, string(body))
232+
}
233+
if len(prov.scaleCalls) != 1 {
234+
t.Errorf("expected one Scale call before re-read, got %v", prov.scaleCalls)
235+
}
236+
require.NoError(t, mock.ExpectationsWereMet())
237+
}
238+
127239
func TestWake_HappyPath(t *testing.T) {
128240
db, mock, err := sqlmock.New()
129241
require.NoError(t, err)

internal/models/deployment_scale_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ package models_test
1111

1212
import (
1313
"context"
14+
"errors"
1415
"testing"
1516
"time"
1617

18+
sqlmock "github.com/DATA-DOG/go-sqlmock"
1719
"github.com/google/uuid"
1820
"github.com/stretchr/testify/assert"
1921
"github.com/stretchr/testify/require"
@@ -22,6 +24,60 @@ import (
2224
"instant.dev/internal/testhelpers"
2325
)
2426

27+
// errScaleDriver is the sentinel sqlmock returns for the driver-error arms of
28+
// the scale-to-zero model writes. Named so the wrapped %w error is searchable.
29+
var errScaleDriver = errors.New("mock: deployments UPDATE exploded")
30+
31+
// TestMarkDeploymentScaledToZero_DriverError pins the error return (the
32+
// fmt.Errorf wrap) when the UPDATE itself fails — distinct from a 0-row CAS
33+
// miss, which is not an error.
34+
func TestMarkDeploymentScaledToZero_DriverError(t *testing.T) {
35+
db, mock, err := sqlmock.New()
36+
require.NoError(t, err)
37+
defer db.Close()
38+
39+
id := uuid.New()
40+
mock.ExpectExec(`UPDATE deployments`).WithArgs(id).WillReturnError(errScaleDriver)
41+
42+
n, err := models.MarkDeploymentScaledToZero(context.Background(), db, id)
43+
require.Error(t, err)
44+
assert.ErrorIs(t, err, errScaleDriver)
45+
assert.Equal(t, int64(0), n)
46+
require.NoError(t, mock.ExpectationsWereMet())
47+
}
48+
49+
// TestWakeDeployment_DriverError pins WakeDeployment's error return.
50+
func TestWakeDeployment_DriverError(t *testing.T) {
51+
db, mock, err := sqlmock.New()
52+
require.NoError(t, err)
53+
defer db.Close()
54+
55+
id := uuid.New()
56+
mock.ExpectExec(`UPDATE deployments`).WithArgs(id).WillReturnError(errScaleDriver)
57+
58+
n, err := models.WakeDeployment(context.Background(), db, id)
59+
require.Error(t, err)
60+
assert.ErrorIs(t, err, errScaleDriver)
61+
assert.Equal(t, int64(0), n)
62+
require.NoError(t, mock.ExpectationsWereMet())
63+
}
64+
65+
// TestSetDeploymentAlwaysOn_DriverError pins SetDeploymentAlwaysOn's error return.
66+
func TestSetDeploymentAlwaysOn_DriverError(t *testing.T) {
67+
db, mock, err := sqlmock.New()
68+
require.NoError(t, err)
69+
defer db.Close()
70+
71+
id := uuid.New()
72+
mock.ExpectExec(`UPDATE deployments`).WithArgs(id, true).WillReturnError(errScaleDriver)
73+
74+
n, err := models.SetDeploymentAlwaysOn(context.Background(), db, id, true)
75+
require.Error(t, err)
76+
assert.ErrorIs(t, err, errScaleDriver)
77+
assert.Equal(t, int64(0), n)
78+
require.NoError(t, mock.ExpectationsWereMet())
79+
}
80+
2581
func TestCreateDeployment_SeedsScaleToZeroDefaults(t *testing.T) {
2682
requireDB(t)
2783
db, cleanDB := testhelpers.SetupTestDB(t)

0 commit comments

Comments
 (0)