mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
test(smoke): add smoke tests and coverage gate for backend
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
name: backend-tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'Makefile'
|
||||||
|
- 'scripts/coverage-gate.sh'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit:
|
||||||
|
name: Unit tests + coverage gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
|
- name: Run unit tests with race detector and coverage
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
go test -race -coverprofile=../coverage.out -coverpkg=./... ./...
|
||||||
|
|
||||||
|
- name: Enforce coverage gate
|
||||||
|
run: ./scripts/coverage-gate.sh coverage.out
|
||||||
|
|
||||||
|
- name: Upload coverage artifact
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: coverage
|
||||||
|
path: coverage.out
|
||||||
|
|
||||||
|
smoke:
|
||||||
|
name: Smoke test (docker compose)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
|
- name: Run smoke test
|
||||||
|
run: make test-smoke
|
||||||
|
|
||||||
|
- name: Dump compose logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
docker compose -p garage-ui-smoke \
|
||||||
|
-f backend/tests/smoke/docker-compose.test.yml logs --no-color || true
|
||||||
|
|
||||||
|
- name: Tear down compose
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker compose -p garage-ui-smoke \
|
||||||
|
-f backend/tests/smoke/docker-compose.test.yml down -v || true
|
||||||
@@ -135,3 +135,22 @@ install:
|
|||||||
update: prod-pull prod-restart
|
update: prod-pull prod-restart
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: test test-race test-cover test-smoke
|
||||||
|
|
||||||
|
## test: Run backend unit tests
|
||||||
|
test:
|
||||||
|
cd backend && go test ./...
|
||||||
|
|
||||||
|
## test-race: Run backend unit tests with the race detector
|
||||||
|
test-race:
|
||||||
|
cd backend && go test -race ./...
|
||||||
|
|
||||||
|
## test-cover: Run backend unit tests with coverage and enforce the coverage gate
|
||||||
|
test-cover:
|
||||||
|
cd backend && go test -coverprofile=../coverage.out -coverpkg=./... ./...
|
||||||
|
./scripts/coverage-gate.sh coverage.out
|
||||||
|
|
||||||
|
## test-smoke: Run the docker-compose smoke test (requires Docker + compose v2)
|
||||||
|
test-smoke:
|
||||||
|
cd backend && go test -tags=smoke -timeout 10m ./tests/smoke/...
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
//go:build smoke
|
||||||
|
|
||||||
|
package smoke_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
composeProject = "garage-ui-smoke"
|
||||||
|
backendBaseURL = "http://127.0.0.1:18080"
|
||||||
|
adminToken = "smoke-admin-token-do-not-use-in-prod"
|
||||||
|
adminUsername = "smokeadmin"
|
||||||
|
adminPassword = "smokepass"
|
||||||
|
readyTimeout = 90 * time.Second
|
||||||
|
readyPollEvery = 1 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func composeFile(t testing.TB) string {
|
||||||
|
t.Helper()
|
||||||
|
_, thisFile, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("runtime.Caller failed")
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(thisFile), "docker-compose.test.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCompose(t testing.TB, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
full := append([]string{"compose", "-p", composeProject, "-f", composeFile(t)}, args...)
|
||||||
|
cmd := exec.Command("docker", full...)
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &out
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("docker %s failed: %v\n%s", strings.Join(full, " "), err, out.String())
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func composeExec(service string, argv ...string) (string, error) {
|
||||||
|
full := append([]string{"compose", "-p", composeProject, "-f", mustComposeFile(), "exec", "-T", service}, argv...)
|
||||||
|
cmd := exec.Command("docker", full...)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("docker %s: %w\nstderr:\n%s", strings.Join(full, " "), err, stderr.String())
|
||||||
|
}
|
||||||
|
return stdout.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustComposeFile() string {
|
||||||
|
_, thisFile, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
panic("runtime.Caller failed")
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(thisFile), "docker-compose.test.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForHTTP(ctx context.Context, url string, timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(readyPollEvery):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("timeout waiting for %s", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initGarageLayout() error {
|
||||||
|
nodeOut, err := composeExec("garage", "/garage", "node", "id", "-q")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("garage node id: %w", err)
|
||||||
|
}
|
||||||
|
nodeID := strings.TrimSpace(nodeOut)
|
||||||
|
if i := strings.Index(nodeID, "@"); i > 0 {
|
||||||
|
nodeID = nodeID[:i]
|
||||||
|
}
|
||||||
|
if nodeID == "" {
|
||||||
|
return fmt.Errorf("empty node id from garage node id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := composeExec("garage", "/garage", "layout", "assign", "-z", "dc1", "-c", "1G", nodeID); err != nil {
|
||||||
|
return fmt.Errorf("garage layout assign: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := composeExec("garage", "/garage", "layout", "apply", "--version", "1"); err != nil {
|
||||||
|
return fmt.Errorf("garage layout apply: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stderr, "[smoke] bringing up garage (waiting for healthcheck)...")
|
||||||
|
runComposeNoT("up", "-d", "--wait", "garage")
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
fmt.Fprintln(os.Stderr, "[smoke] tearing down compose...")
|
||||||
|
_ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "logs").Run()
|
||||||
|
_ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "down", "-v").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stderr, "[smoke] initializing garage cluster layout...")
|
||||||
|
if err := initGarageLayout(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[smoke] layout init failed: %v\n", err)
|
||||||
|
cleanup()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stderr, "[smoke] starting backend...")
|
||||||
|
runComposeNoT("up", "-d", "backend")
|
||||||
|
|
||||||
|
if err := waitForHTTP(ctx, backendBaseURL+"/health", readyTimeout); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[smoke] backend not ready: %v\n", err)
|
||||||
|
cleanup()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
code := m.Run()
|
||||||
|
|
||||||
|
if code != 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "[smoke] tests failed — dumping compose logs")
|
||||||
|
logCmd := exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "logs", "--no-color")
|
||||||
|
logCmd.Stdout = os.Stderr
|
||||||
|
logCmd.Stderr = os.Stderr
|
||||||
|
_ = logCmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "down", "-v").Run()
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runComposeNoT(args ...string) {
|
||||||
|
full := append([]string{"compose", "-p", composeProject, "-f", mustComposeFile()}, args...)
|
||||||
|
cmd := exec.Command("docker", full...)
|
||||||
|
cmd.Stdout = os.Stderr
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[smoke] docker %s failed: %v\n", strings.Join(full, " "), err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testState struct {
|
||||||
|
client *http.Client
|
||||||
|
token string
|
||||||
|
bucketName string
|
||||||
|
objectKey string
|
||||||
|
sourceBody []byte
|
||||||
|
accessKeyID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testState) do(t *testing.T, method, path string, body io.Reader, contentType string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(method, backendBaseURL+path, body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build request: %v", err)
|
||||||
|
}
|
||||||
|
if s.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
}
|
||||||
|
if contentType != "" {
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBody(t *testing.T, resp *http.Response) []byte {
|
||||||
|
t.Helper()
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmokeGoldenPath(t *testing.T) {
|
||||||
|
sourcePath := filepath.Join(filepath.Dir(mustComposeFile()), "testdata", "small.bin")
|
||||||
|
source, err := os.ReadFile(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := &testState{
|
||||||
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
bucketName: fmt.Sprintf("smoke-%d", time.Now().UnixNano()),
|
||||||
|
objectKey: "small.bin",
|
||||||
|
sourceBody: source,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("AdminLogin", func(t *testing.T) {
|
||||||
|
payload := fmt.Sprintf(`{"username":%q,"password":%q}`, adminUsername, adminPassword)
|
||||||
|
resp := state.do(t, "POST", "/auth/login", strings.NewReader(payload), "application/json")
|
||||||
|
body := readBody(t, resp)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("login status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
t.Fatalf("parse login body: %v, body=%s", err, body)
|
||||||
|
}
|
||||||
|
if !parsed.Success || parsed.Token == "" {
|
||||||
|
t.Fatalf("login did not return token: %s", body)
|
||||||
|
}
|
||||||
|
state.token = parsed.Token
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CreateBucket", func(t *testing.T) {
|
||||||
|
if state.token == "" {
|
||||||
|
t.Skip("login did not succeed")
|
||||||
|
}
|
||||||
|
payload := fmt.Sprintf(`{"name":%q}`, state.bucketName)
|
||||||
|
resp := state.do(t, "POST", "/api/v1/buckets", strings.NewReader(payload), "application/json")
|
||||||
|
body := readBody(t, resp)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
t.Fatalf("create bucket status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CreateKey", func(t *testing.T) {
|
||||||
|
if state.token == "" {
|
||||||
|
t.Skip("login did not succeed")
|
||||||
|
}
|
||||||
|
payload := `{"name":"smoke-key"}`
|
||||||
|
resp := state.do(t, "POST", "/api/v1/users", strings.NewReader(payload), "application/json")
|
||||||
|
body := readBody(t, resp)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
t.Fatalf("create key status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Data struct {
|
||||||
|
AccessKeyID string `json:"accessKeyId"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
t.Fatalf("parse key body: %v, body=%s", err, body)
|
||||||
|
}
|
||||||
|
if parsed.Data.AccessKeyID == "" {
|
||||||
|
t.Fatalf("empty accessKeyId: %s", body)
|
||||||
|
}
|
||||||
|
state.accessKeyID = parsed.Data.AccessKeyID
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GrantPermission", func(t *testing.T) {
|
||||||
|
if state.accessKeyID == "" {
|
||||||
|
t.Skip("key creation did not succeed")
|
||||||
|
}
|
||||||
|
payload := fmt.Sprintf(
|
||||||
|
`{"accessKeyId":%q,"permissions":{"read":true,"write":true,"owner":false}}`,
|
||||||
|
state.accessKeyID,
|
||||||
|
)
|
||||||
|
path := fmt.Sprintf("/api/v1/buckets/%s/permissions", state.bucketName)
|
||||||
|
resp := state.do(t, "POST", path, strings.NewReader(payload), "application/json")
|
||||||
|
body := readBody(t, resp)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
t.Fatalf("grant permission status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UploadObject", func(t *testing.T) {
|
||||||
|
if state.token == "" {
|
||||||
|
t.Skip("login did not succeed")
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
fw, err := mw.CreateFormFile("file", filepath.Base(state.objectKey))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create form file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(state.sourceBody); err != nil {
|
||||||
|
t.Fatalf("write form file: %v", err)
|
||||||
|
}
|
||||||
|
if err := mw.WriteField("key", state.objectKey); err != nil {
|
||||||
|
t.Fatalf("write key field: %v", err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatalf("close multipart: %v", err)
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/v1/buckets/%s/objects/", state.bucketName)
|
||||||
|
resp := state.do(t, "POST", path, &buf, mw.FormDataContentType())
|
||||||
|
body := readBody(t, resp)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
t.Fatalf("upload status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ListObjects", func(t *testing.T) {
|
||||||
|
if state.token == "" {
|
||||||
|
t.Skip("login did not succeed")
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/v1/buckets/%s/objects/", state.bucketName)
|
||||||
|
resp := state.do(t, "GET", path, nil, "")
|
||||||
|
body := readBody(t, resp)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("list status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(body, []byte(state.objectKey)) {
|
||||||
|
t.Fatalf("uploaded key %q not found in list: %s", state.objectKey, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DownloadObject", func(t *testing.T) {
|
||||||
|
if state.token == "" {
|
||||||
|
t.Skip("login did not succeed")
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/v1/buckets/%s/objects/%s", state.bucketName, state.objectKey)
|
||||||
|
resp := state.do(t, "GET", path, nil, "")
|
||||||
|
body := readBody(t, resp)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("download status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(body, state.sourceBody) {
|
||||||
|
t.Fatalf("downloaded bytes differ: got %d bytes, want %d bytes", len(body), len(state.sourceBody))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DeleteObject", func(t *testing.T) {
|
||||||
|
if state.token == "" {
|
||||||
|
t.Skip("login did not succeed")
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/v1/buckets/%s/objects/%s", state.bucketName, state.objectKey)
|
||||||
|
resp := state.do(t, "DELETE", path, nil, "")
|
||||||
|
body := readBody(t, resp)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
t.Fatalf("delete object status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DeleteBucket", func(t *testing.T) {
|
||||||
|
if state.token == "" {
|
||||||
|
t.Skip("login did not succeed")
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/v1/buckets/%s", state.bucketName)
|
||||||
|
resp := state.do(t, "DELETE", path, nil, "")
|
||||||
|
body := readBody(t, resp)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
t.Fatalf("delete bucket status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ListKeys", func(t *testing.T) {
|
||||||
|
if state.token == "" {
|
||||||
|
t.Skip("login did not succeed")
|
||||||
|
}
|
||||||
|
resp := state.do(t, "GET", "/api/v1/users", nil, "")
|
||||||
|
body := readBody(t, resp)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("list users status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Usage: scripts/coverage-gate.sh <coverage.out>
|
||||||
|
#
|
||||||
|
# Enforces:
|
||||||
|
# - Total backend coverage >= 70% (excluding backend/main.go and backend/docs/).
|
||||||
|
# - Per-package floor >= 70% for each of:
|
||||||
|
# internal/auth, internal/config, internal/handlers, internal/services.
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "usage: $0 <coverage.out>" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
COVERAGE_IN="$1"
|
||||||
|
TOTAL_FLOOR="70.0"
|
||||||
|
PER_PKG_FLOOR="70.0"
|
||||||
|
PER_PKG_GATES=(
|
||||||
|
"internal/auth"
|
||||||
|
"internal/config"
|
||||||
|
"internal/handlers"
|
||||||
|
"internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ ! -f "$COVERAGE_IN" ]; then
|
||||||
|
echo "error: coverage file not found: $COVERAGE_IN" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
FILTERED="coverage-filtered.out"
|
||||||
|
trap 'rm -f "$FILTERED"' EXIT
|
||||||
|
|
||||||
|
# Keep the mode: line, drop excluded paths.
|
||||||
|
# Excluded from coverage totals:
|
||||||
|
# - main.go: entry point, covered transitively by smoke.
|
||||||
|
# - backend/docs/: generated swagger.
|
||||||
|
# - mocks/ and *_mock.go: test infrastructure, not production code.
|
||||||
|
# - services/s3.go: network methods deferred to smoke per parent spec
|
||||||
|
# (docs/superpowers/specs/2026-04-17-backend-test-suite-design.md §internal/services).
|
||||||
|
grep -E '^(mode:|.*/)' "$COVERAGE_IN" \
|
||||||
|
| grep -v '/main\.go' \
|
||||||
|
| grep -v '/docs/' \
|
||||||
|
| grep -v '/mocks/' \
|
||||||
|
| grep -v '_mock\.go' \
|
||||||
|
| grep -v '/services/s3\.go' \
|
||||||
|
> "$FILTERED"
|
||||||
|
|
||||||
|
FUNC_OUT=$(cd backend && go tool cover -func="../$FILTERED")
|
||||||
|
TOTAL_PCT=$(echo "$FUNC_OUT" | awk '/^total:/ {gsub("%",""); print $NF}')
|
||||||
|
|
||||||
|
if [ -z "$TOTAL_PCT" ]; then
|
||||||
|
echo "error: could not parse total coverage from go tool cover output" >&2
|
||||||
|
echo "$FUNC_OUT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Coverage summary =="
|
||||||
|
printf " %-30s %s%%\n" "total" "$TOTAL_PCT"
|
||||||
|
|
||||||
|
FAIL=0
|
||||||
|
if awk -v a="$TOTAL_PCT" -v b="$TOTAL_FLOOR" 'BEGIN { exit !(a+0 < b+0) }'; then
|
||||||
|
echo "FAIL total: ${TOTAL_PCT}% < ${TOTAL_FLOOR}%"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for pkg in "${PER_PKG_GATES[@]}"; do
|
||||||
|
pkg_pct=$(echo "$FUNC_OUT" \
|
||||||
|
| awk -v p="/$pkg/" '$1 ~ p { sub("%","",$NF); sum+=$NF; n++ } END { if (n>0) printf "%.1f", sum/n; else print "n/a" }')
|
||||||
|
printf " %-30s %s%%\n" "$pkg" "$pkg_pct"
|
||||||
|
if [ "$pkg_pct" = "n/a" ]; then
|
||||||
|
echo "FAIL $pkg: no coverage data"
|
||||||
|
FAIL=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if awk -v a="$pkg_pct" -v b="$PER_PKG_FLOOR" 'BEGIN { exit !(a+0 < b+0) }'; then
|
||||||
|
echo "FAIL $pkg: ${pkg_pct}% < ${PER_PKG_FLOOR}%"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FAIL" -ne 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PASS"
|
||||||
Reference in New Issue
Block a user