|
| 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