Skip to content
quarry Artifact [825fb0b56c]
Login

Artifact [825fb0b56c]

Artifact 825fb0b56c684e1720cda7c4c0d05bd8d2e32eb55216abee5a6a6b4ad3c077b3:


package api

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/ops"
)

type slowAPIIn struct{}
type slowAPIOut struct {
	OK bool `json:"ok"`
}

// An op called over /api/v1 was never timed: the slow-op log and the
// ops history lived in the web's page helper, so a slow ticket.set from
// an agent or the runner was invisible on /fleet/ops. It is filed now,
// beside the web renders, tagged with the surface it came in on.
func TestASlowAPICallReachesTheOpsHistoryAndTheSlowOpLog(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{})}); err != nil {
		t.Fatal(err)
	}
	// A wildcard so every run has its own URL: the recorder is the
	// process-wide one, and a repeated run must not count the last.
	const route = "GET /api/v1/test/slow-op-262/{run}"
	url := fmt.Sprintf("/api/v1/test/slow-op-262/%d", time.Now().UnixNano())
	if err := ops.Register(r, ops.Op[slowAPIIn, slowAPIOut]{
		Name: "test.slow262", Doc: "sleeps past the slow-op threshold",
		Kind: ops.Query, Auth: ops.Anonymous, Route: route,
		Run: func(context.Context, slowAPIIn) (slowAPIOut, error) {
			time.Sleep(300 * time.Millisecond)
			return slowAPIOut{OK: true}, nil
		},
	}); err != nil {
		t.Fatal(err)
	}
	h, err := Router(r, "v")
	if err != nil {
		t.Fatal(err)
	}

	var buf bytes.Buffer
	prev := log.Writer()
	log.SetOutput(&buf)
	t.Cleanup(func() { log.SetOutput(prev) })

	w := httptest.NewRecorder()
	h.ServeHTTP(w, httptest.NewRequest("GET", url, nil))
	if w.Code != http.StatusOK {
		t.Fatalf("slow op answered %d: %s", w.Code, w.Body)
	}

	var slow []string
	for _, l := range strings.Split(buf.String(), "\n") {
		if strings.Contains(l, "slow op: test.slow262 ") {
			slow = append(slow, l)
		}
	}
	if len(slow) != 1 {
		t.Fatalf("%d slow-op lines for the API call, want 1:\n%s", len(slow), buf.String())
	}
	if !strings.Contains(slow[0], "surface=api") {
		t.Errorf("the slow-op line does not say it came over the API: %q", slow[0])
	}

	// The ops history is read the way the page reads it: through
	// ops.status, over the API.
	w = httptest.NewRecorder()
	h.ServeHTTP(w, httptest.NewRequest("GET", "/api/v1/ops?limit=200", nil))
	if w.Code != http.StatusOK {
		t.Fatalf("ops.status answered %d: %s", w.Code, w.Body)
	}
	var out struct {
		Renders []map[string]any `json:"renders"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
		t.Fatal(err)
	}
	var rows []map[string]any
	for _, row := range out.Renders {
		if row["path"] == route && row["url"] == url {
			rows = append(rows, row)
		}
	}
	if len(rows) != 1 {
		t.Fatalf("%d history rows for the API call, want 1: %v", len(rows), out.Renders)
	}
	row := rows[0]
	if row["surface"] != "api" {
		t.Errorf("surface = %v, want api", row["surface"])
	}
	if ms, _ := row["opsMs"].(float64); ms < 250 {
		t.Errorf("opsMs = %v, want the op's own time", row["opsMs"])
	}
	if st, _ := row["status"].(float64); st != http.StatusOK {
		t.Errorf("status = %v, want 200", row["status"])
	}
}