Ctrl K
ComunidadGuías…

schedparked_notice_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
	"time"

	"github.com/pingular/pingularity/internal/store"
)

// A schedule switched on with no weekday selected can never be active, so the
// feature it gates never runs - and an install measuring nothing looks exactly
// like an install with nothing to report. The dashboard refuses to save that
// state, so it arrives from the settings API, a restored backup or a
// hand-edited database, where nothing else would ever mention it. Boot the REAL
// binary on a store holding one and require the boot output to name the
// schedule, what it parked, and the way out.
func TestParkedScheduleStartupOutputSaysProbingIsParked(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping build-and-boot parked-schedule notice test in -short mode")
	}
	goBin, err := exec.LookPath("go")
	if err != nil {
		t.Skip("go toolchain not on PATH; skipping build-and-boot parked-schedule notice test")
	}

	dir := t.TempDir()
	bin := filepath.Join(dir, "pingularity")
	if runtime.GOOS == "windows" {
		bin += ".exe"
	}
	// Stamped with a version of its own, so the boots below can tell this
	// binary's daemon from any other answering on the same port.
	version := noticeBuildVersion()
	build := exec.Command(goBin, "build", "-ldflags=-X main.version="+version, "-o", bin, ".")
	build.Stderr = os.Stderr
	if err := build.Run(); err != nil {
		t.Fatalf("build failed: %v", err)
	}

	// The rows an older build leaves behind, or a config script writes: the
	// toggle on, and one window that selects no day.
	parked := bootSeededInstall(t, bin, version, map[string]string{
		"sched_lat_enabled": "1",
		"sched_lat_windows": `[{"days":"0000000","start":0,"end":0}]`,
	})
	for _, want := range []string{
		"latency schedule", // which schedule
		"no active days",   // what is wrong with it, in the dashboard's own words
		"parked",           // what that costs
		"pick a day",       // the way out, also the dashboard's own words
	} {
		if !strings.Contains(parked, want) {
			t.Errorf("boot output on a parked latency schedule never says %q - the install measures nothing and says nothing.\nfull output:\n%s", want, parked)
		}
	}

	// The same schedule with a real day in it is an ordinary schedule: it gates
	// probing, as it is meant to, and a notice here would teach operators to
	// ignore the one above.
	live := bootSeededInstall(t, bin, version, map[string]string{
		"sched_lat_enabled": "1",
		"sched_lat_windows": `[{"days":"0111110","start":540,"end":1020}]`,
	})
	if strings.Contains(live, "parked") {
		t.Errorf("boot output on a working schedule announces a parked one:\n%s", live)
	}
}

// bootSeededInstall writes rows into a fresh store the way an older build or a
// config restore leaves them, boots the already-built binary against it until
// that binary is serving (see bootUntilServing), and returns everything it
// printed on both streams.
func bootSeededInstall(t *testing.T, bin, version string, rows map[string]string) string {
	t.Helper()
	out, _ := bootUntilServing(t, bin, version, func(dbPath string) {
		st, err := store.Open(dbPath)
		if err != nil {
			t.Fatalf("open %s: %v", dbPath, err)
		}
		if _, err := st.SetSettingsDiff(context.Background(), rows); err != nil {
			st.Close()
			t.Fatalf("seed: %v", err)
		}
		st.Close()
	})
	return out
}

// noticeBuildVersion is the version a boot-notice test stamps into the binary
// it builds: unique to this process and this build, so that daemon can be told
// apart from every other one on the machine.
func noticeBuildVersion() string {
	return fmt.Sprintf("notice-test-%d-%d", os.Getpid(), time.Now().UnixNano())
}

// bootUntilServing boots the already-built binary on a store that seed
// prepares, on a free high port, and waits until THAT binary answers
// /api/status - so boot is past every startup line - then kills it and returns
// everything it printed on both streams plus the -listen address it used.
//
// Any 200 on the port is not the same answer. reserveHighPort hands every test
// process the lowest free port from 19000 up and lets go of it just before the
// child binds, so two processes running at once can be handed the same one:
// one child loses the bind and exits, and a poll that takes any 200 gets it
// from the OTHER process's daemon, kills its own child before that child has
// printed a word, and reports the notice missing from an empty output. So the
// answer has to carry the version this test stamped into its own build, and a
// child that exits before it is seen serving is booted again - on a fresh port
// and a fresh store - rather than reported as a boot that said nothing. A daemon
// that never serves at all still fails, with what it printed.
func bootUntilServing(t *testing.T, bin, version string, seed func(dbPath string)) (output, listenAddr string) {
	t.Helper()
	const attempts = 5
	last := ""
	for i := 0; i < attempts; i++ {
		dbPath := filepath.Join(t.TempDir(), "seeded.db")
		seed(dbPath)
		port, releasePort := reserveHighPort(t)
		addr := "127.0.0.1:" + port
		// -quick-setup=skip answers the first-run offer: these installs have been
		// answered already, and the point is the boot output, not the hold.
		cmd := exec.Command(bin, "run", "-listen", addr, "-db", dbPath, "-quick-setup=skip")
		// Buffers rather than pipes: Wait closes a pipe the moment the child exits,
		// which can beat the reader to the boot lines and hand the assertions an
		// empty output that reads as "the notice was never printed". Wait drains a
		// buffer's copier before it returns, and nothing here reads the buffers
		// until it has.
		var outBuf, errBuf bytes.Buffer
		cmd.Stdout, cmd.Stderr = &outBuf, &errBuf
		releasePort()
		if err := cmd.Start(); err != nil {
			t.Fatalf("start failed: %v", err)
		}
		exited := make(chan struct{})
		go func() {
			_ = cmd.Wait()
			close(exited)
		}()
		ready := servesAsVersion(addr, version, exited)
		select {
		case <-exited:
		default:
			_ = cmd.Process.Kill()
			<-exited
		}
		if ready {
			return outBuf.String() + errBuf.String(), addr
		}
		last = "stdout:\n" + outBuf.String() + "\nstderr:\n" + errBuf.String()
	}
	t.Fatalf("the daemon built as %q never served /api/status in %d boots; the last one printed\n%s", version, attempts, last)
	return "", ""
}

// servesAsVersion polls addr until the daemon answering there reports version,
// the child exits, or thirty seconds pass.
func servesAsVersion(addr, version string, exited <-chan struct{}) bool {
	client := &http.Client{Timeout: 2 * time.Second}
	deadline := time.Now().Add(30 * time.Second)
	for time.Now().Before(deadline) {
		select {
		case <-exited:
			return false
		default:
		}
		if resp, err := client.Get("http://" + addr + "/api/status"); err == nil {
			var status struct {
				Version string `json:"version"`
			}
			decodeErr := json.NewDecoder(resp.Body).Decode(&status)
			resp.Body.Close()
			if resp.StatusCode == http.StatusOK && decodeErr == nil && status.Version == version {
				return true
			}
		}
		time.Sleep(100 * time.Millisecond)
	}
	return false
}

// The notice must be ONE line, in the startup-line voice, and it must name the
// schedule, what it parked and the way out - the point of the line is that
// nobody should have to go read the docs to find out why the install is
// measuring nothing. The exit is worded as the dashboard words it, so the two
// places an operator meets this state say the same thing.
func TestSchedParkedLineIsOneActionableLine(t *testing.T) {
	for feature, parked := range map[string]string{
		"latency":   "latency probing is parked",
		"speedtest": "no automatic speedtest will run",
	} {
		line := schedParkedLine(feature)
		if strings.Contains(line, "\n") {
			t.Errorf("parked-schedule notice for %q is not a single line: %q", feature, line)
		}
		if !strings.HasPrefix(line, "pingularity") {
			t.Errorf("parked-schedule notice for %q breaks the startup-line style (no \"pingularity\" prefix): %q", feature, line)
		}
		for _, want := range []string{
			feature + " schedule", // which schedule is at fault
			"no active days",      // what is wrong with it
			parked,                // what that costs, in this feature's own terms
			"pick a day or turn it off",
		} {
			if !strings.Contains(line, want) {
				t.Errorf("parked-schedule notice for %q missing %q: %q", feature, want, line)
			}
		}
	}
	// The two features must not render the same sentence: an operator with both
	// schedules parked gets two lines, and two identical lines say nothing about
	// which is which.
	if schedParkedLine("latency") == schedParkedLine("speedtest") {
		t.Error("both schedules render the same notice - neither line says which feature stopped")
	}
}