diff --git a/internal/handlers/deploy.go b/internal/handlers/deploy.go index 76a2753..4253dd0 100644 --- a/internal/handlers/deploy.go +++ b/internal/handlers/deploy.go @@ -652,17 +652,30 @@ func (h *DeployHandler) Redeploy(c *fiber.Ctx) error { // ── GET /api/v1/deployments ─────────────────────────────────────────────────── -// List handles GET /api/v1/deployments — list all deployments for the team. +// List handles GET /api/v1/deployments — list deployments for the team. +// Accepts an optional ?env= query parameter to filter by environment. +// When omitted, returns all envs. func (h *DeployHandler) List(c *fiber.Ctx) error { team, err := h.requireTeam(c) if err != nil { return err } - deploys, err := models.GetDeploymentsByTeam(c.Context(), h.db, team.ID) + envFilter := c.Query("env") + var deploys []*models.Deployment + if envFilter != "" { + normalized, ok := models.NormalizeEnv(envFilter) + if !ok { + return c.JSON(fiber.Map{"ok": true, "items": []fiber.Map{}, "total": 0}) + } + deploys, err = models.GetDeploymentsByTeamAndEnv(c.Context(), h.db, team.ID, normalized) + } else { + deploys, err = models.GetDeploymentsByTeam(c.Context(), h.db, team.ID) + } if err != nil { slog.Error("deploy.list.failed", "error", err, "team_id", team.ID, + "env_filter", envFilter, "request_id", middleware.GetRequestID(c)) return respondError(c, fiber.StatusServiceUnavailable, "list_failed", "Failed to list deployments") } diff --git a/internal/handlers/list_env_filter_test.go b/internal/handlers/list_env_filter_test.go new file mode 100644 index 0000000..2264317 --- /dev/null +++ b/internal/handlers/list_env_filter_test.go @@ -0,0 +1,240 @@ +package handlers_test + +// Tests the ?env= query parameter on GET /api/v1/resources and +// GET /api/v1/deployments — the slice-1 behavior change in the env-aware +// deployments work. Verifies: +// - Omitting ?env= returns all envs (backward compat). +// - ?env=staging returns only staging rows. +// - ?env= returns 200 + empty array (UI-stable, never 400). + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/testhelpers" +) + +const ( + envProduction = "production" + envStaging = "staging" +) + +type listResp struct { + OK bool `json:"ok"` + Items []map[string]any `json:"items"` + Total int `json:"total"` +} + +// ── GET /api/v1/resources?env= ─────────────────────────────────────────────── + +func TestResourceList_NoEnvFilter_ReturnsAllEnvs(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + jwt := testhelpers.MustSignSessionJWT(t, "user-list-noenv", teamID, "noenv@example.com") + + insertResource(t, db, teamID, "postgres", envProduction) + insertResource(t, db, teamID, "redis", envStaging) + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + body := callList(t, app, "/api/v1/resources", jwt) + assert.True(t, body.OK) + assert.Equal(t, 2, body.Total, "no filter must return both envs") + assert.Len(t, body.Items, 2) +} + +func TestResourceList_EnvStaging_ReturnsOnlyStaging(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + jwt := testhelpers.MustSignSessionJWT(t, "user-list-staging", teamID, "staging@example.com") + + insertResource(t, db, teamID, "postgres", envProduction) + insertResource(t, db, teamID, "redis", envStaging) + insertResource(t, db, teamID, "mongodb", envStaging) + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + body := callList(t, app, "/api/v1/resources?env=staging", jwt) + assert.True(t, body.OK) + assert.Equal(t, 2, body.Total, "env=staging must return 2") + for _, item := range body.Items { + assert.Equal(t, envStaging, item["env"], "every item must be staging") + } +} + +func TestResourceList_BogusEnv_ReturnsEmptyOK(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + jwt := testhelpers.MustSignSessionJWT(t, "user-list-bogus", teamID, "bogus@example.com") + + insertResource(t, db, teamID, "postgres", envProduction) + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + // Uppercase + a space — fails NormalizeEnv shape. UI-stable: 200 + empty. + body := callList(t, app, "/api/v1/resources?env=PROD%20", jwt) + assert.True(t, body.OK) + assert.Equal(t, 0, body.Total) + assert.Empty(t, body.Items) +} + +// ── GET /api/v1/deployments?env= ───────────────────────────────────────────── + +func TestDeployList_NoEnvFilter_ReturnsAllEnvs(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + jwt := testhelpers.MustSignSessionJWT(t, "deploy-noenv", teamID, "deploy-noenv@example.com") + + insertDeployment(t, db, teamID, "app-prod-noenv", envProduction) + insertDeployment(t, db, teamID, "app-stage-noenv", envStaging) + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + body := callList(t, app, "/api/v1/deployments", jwt) + assert.True(t, body.OK) + assert.Equal(t, 2, body.Total) +} + +func TestDeployList_EnvStaging_ReturnsOnlyStaging(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + jwt := testhelpers.MustSignSessionJWT(t, "deploy-stage", teamID, "deploy-stage@example.com") + + insertDeployment(t, db, teamID, "app-prod-only", envProduction) + insertDeployment(t, db, teamID, "app-stage-1", envStaging) + insertDeployment(t, db, teamID, "app-stage-2", envStaging) + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + body := callList(t, app, "/api/v1/deployments?env=staging", jwt) + assert.True(t, body.OK) + assert.Equal(t, 2, body.Total) + // Both items must be staging; the server may use either "env" or "environment" + // as the env-scope key — accept either to match the dashboard's adapter shim. + for _, item := range body.Items { + scope, _ := envScopeFromDeploy(item) + assert.Equal(t, envStaging, scope, "every item must be staging") + } +} + +func TestDeployList_BogusEnv_ReturnsEmptyOK(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + jwt := testhelpers.MustSignSessionJWT(t, "deploy-bogus", teamID, "deploy-bogus@example.com") + + insertDeployment(t, db, teamID, "app-prod-bogus", envProduction) + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + body := callList(t, app, "/api/v1/deployments?env=%20Production%20", jwt) + assert.True(t, body.OK) + assert.Equal(t, 0, body.Total) + assert.Empty(t, body.Items) +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +func insertResource(t *testing.T, db *sql.DB, teamID, resourceType, env string) { + t.Helper() + var id string + require.NoError(t, db.QueryRowContext(context.Background(), ` + INSERT INTO resources (team_id, resource_type, tier, env) + VALUES ($1::uuid, $2, 'hobby', $3) + RETURNING id::text + `, teamID, resourceType, env).Scan(&id)) +} + +// insertDeployment inserts a deployment row. appIDSuffix is prefixed with the +// teamID (which is fresh per test via MustCreateTeamDB) so re-runs against +// the shared test DB never collide on the app_id UNIQUE index. +func insertDeployment(t *testing.T, db *sql.DB, teamID, appIDSuffix, env string) { + t.Helper() + teamPrefix := strings.ReplaceAll(teamID, "-", "") + if len(teamPrefix) > 12 { + teamPrefix = teamPrefix[:12] + } + appID := fmt.Sprintf("t%s-%s", teamPrefix, appIDSuffix) + var id string + require.NoError(t, db.QueryRowContext(context.Background(), ` + INSERT INTO deployments (team_id, app_id, port, tier, status, env) + VALUES ($1::uuid, $2, 8080, 'hobby', 'healthy', $3) + RETURNING id::text + `, teamID, appID, env).Scan(&id)) +} + +// callList issues a GET to the path with the given JWT and returns the parsed +// {ok, items, total} envelope. +func callList(t *testing.T, app *fiber.App, path, jwt string) listResp { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("X-Forwarded-For", "10.99.0.1") + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + + var out listResp + require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) + return out +} + +// envScopeFromDeploy reads the env-scope from a deployment list item. The +// handler returns the env scope under a stable key (`env`), but some shapes +// in the codebase use `environment` — accept either to stay decoupled from +// that adapter detail. +func envScopeFromDeploy(item map[string]any) (string, bool) { + if v, ok := item["env"].(string); ok && v != "" { + return v, true + } + if v, ok := item["environment"].(string); ok && v != "" { + return v, true + } + return "", false +} diff --git a/internal/handlers/resource.go b/internal/handlers/resource.go index 8a85fb5..5c09467 100644 --- a/internal/handlers/resource.go +++ b/internal/handlers/resource.go @@ -42,7 +42,9 @@ func NewResourceHandler(db *sql.DB, rdb *redis.Client, cfg *config.Config, reg * return &ResourceHandler{db: db, rdb: rdb, cfg: cfg, plans: reg, provisioner: prov, storageProvider: storageProv} } -// List handles GET /api/v1/resources — lists all resources for the authenticated team. +// List handles GET /api/v1/resources — lists resources for the authenticated team. +// Accepts an optional ?env= query parameter to filter by environment. +// Omitting it returns all envs (backward compat with pre-slice-1 callers). func (h *ResourceHandler) List(c *fiber.Ctx) error { requestID := middleware.GetRequestID(c) @@ -51,11 +53,24 @@ func (h *ResourceHandler) List(c *fiber.Ctx) error { return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Valid session token required") } - resources, err := models.ListResourcesByTeam(c.Context(), h.db, teamID) + envFilter := c.Query("env") + var resources []*models.Resource + if envFilter != "" { + // Bogus envs (uppercase, spaces, unicode) fail NormalizeEnv and + // return 200 + empty so the dashboard stays stable on stale state. + normalized, ok := models.NormalizeEnv(envFilter) + if !ok { + return c.JSON(fiber.Map{"ok": true, "items": []fiber.Map{}, "total": 0}) + } + resources, err = models.ListResourcesByTeamAndEnv(c.Context(), h.db, teamID, normalized) + } else { + resources, err = models.ListResourcesByTeam(c.Context(), h.db, teamID) + } if err != nil { slog.Error("resource.list.failed", "error", err, "team_id", teamID, + "env_filter", envFilter, "request_id", requestID, ) return respondError(c, fiber.StatusServiceUnavailable, "list_failed", "Failed to list resources")