Files
pages/internal/clicmd/deployment_test.go
T
2026-08-15 07:13:00 +00:00

327 lines
11 KiB
Go

package clicmd
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cliutil"
)
// cliToken is syntactically plausible and otherwise meaningless: none of the
// fake servers below look at it, but Globals refuses to build a client without
// one.
const cliToken = "pgs_abcdefghijklmnop_secret"
// runCLI drives the real command tree the way main does and returns what the
// user would have seen. Going through Root rather than calling a command's Exec
// directly is the point: it covers the flag registration and the dispatch that
// a hand-built call would skip.
func runCLI(t *testing.T, server, stdin string, args ...string) (stdout, stderr string, err error) {
t.Helper()
clearEnv(t)
t.Setenv("PAGES_TOKEN", cliToken)
var out, errOut strings.Builder
g := &Globals{
In: strings.NewReader(stdin),
Out: &out,
Err: &errOut,
Config: filepath.Join(t.TempDir(), "absent.json"),
Server: server,
}
err = cliutil.Run(context.Background(), Root(g), args, &errOut, g.Register)
return out.String(), errOut.String(), err
}
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Error(err)
}
}
func fakeDeployment(id string) api.Deployment {
return api.Deployment{
ID: id, Project: "demo", State: "ready",
FileCount: 2, TotalBytes: 4096,
CreatedAt: time.Unix(1700000000, 0).UTC(),
}
}
// indexOf is strings.Index with a failure message, used to assert ordering.
func indexOf(t *testing.T, haystack, needle string) int {
t.Helper()
i := strings.Index(haystack, needle)
if i < 0 {
t.Fatalf("output does not mention %q:\n%s", needle, haystack)
}
return i
}
// TestDeploymentListFollowsTheCursor: the listing is read to decide which
// deployment to roll back to, so one that silently showed the first page would
// be worse than one that failed.
func TestDeploymentListFollowsTheCursor(t *testing.T) {
var queries []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
queries = append(queries, r.URL.RawQuery)
if r.URL.Query().Get("cursor") == "dpl_2" {
writeJSON(t, w, api.DeploymentList{
Deployments: []api.Deployment{fakeDeployment("dpl_1")},
})
return
}
writeJSON(t, w, api.DeploymentList{
Deployments: []api.Deployment{fakeDeployment("dpl_3"), fakeDeployment("dpl_2")},
NextCursor: "dpl_2",
})
}))
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "", "deployment", "list", "--project", "demo")
if err != nil {
t.Fatalf("deployment list: %v", err)
}
// Newest first, as the server returned them: the order is what tells the
// reader which one is the previous release.
first := indexOf(t, out, "dpl_3")
second := indexOf(t, out, "dpl_2")
third := indexOf(t, out, "dpl_1")
if !(first < second && second < third) {
t.Errorf("rows out of order:\n%s", out)
}
if len(queries) != 2 {
t.Fatalf("made %d requests (%q), want 2", len(queries), queries)
}
if !strings.Contains(queries[1], "cursor=dpl_2") {
t.Errorf("second request query = %q, want the cursor from the first page", queries[1])
}
}
// TestDeploymentListStopsAtTheLimit — the server here always offers another
// page, so a --limit that was not honoured would page until the test timed out.
func TestDeploymentListStopsAtTheLimit(t *testing.T) {
var requests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if requests > 4 {
t.Errorf("still paging after %d requests; --limit was ignored", requests)
writeJSON(t, w, api.DeploymentList{})
return
}
writeJSON(t, w, api.DeploymentList{
Deployments: []api.Deployment{fakeDeployment("dpl_3"), fakeDeployment("dpl_2")},
NextCursor: "dpl_2",
})
}))
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "", "deployment", "list", "--project", "demo", "--limit", "2")
if err != nil {
t.Fatalf("deployment list: %v", err)
}
if requests != 1 {
t.Errorf("made %d requests, want 1: two rows already satisfy --limit 2", requests)
}
if !strings.Contains(out, "dpl_3") || !strings.Contains(out, "dpl_2") {
t.Errorf("output is missing a row:\n%s", out)
}
}
// TestDeploymentListEmptyIsAnEmptyArray: -o json is what a CI step parses, and
// jq treats null and [] very differently.
func TestDeploymentListEmptyIsAnEmptyArray(t *testing.T) {
var query string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query = r.URL.RawQuery
writeJSON(t, w, api.DeploymentList{})
}))
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "",
"deployment", "list", "--project", "demo", "--state", "failed", "-o", "json")
if err != nil {
t.Fatalf("deployment list: %v", err)
}
if !strings.Contains(query, "state=failed") {
t.Errorf("query = %q, want the state filter", query)
}
if !strings.Contains(out, `"deployments": []`) {
t.Errorf("output = %s, want an empty array", out)
}
if strings.Contains(out, "null") {
t.Errorf("output = %s, want no null", out)
}
}
// TestDeploymentShowAsksForFilesOnlyWhenTold — the manifest is one line per
// file, so a large site's would drown the rest of the output.
func TestDeploymentShowAsksForFilesOnlyWhenTold(t *testing.T) {
d := fakeDeployment("dpl_1")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
out := d
if r.URL.Query().Get("files") == "true" {
out.Files = []api.FileEntry{
{Path: "assets/app.js", Digest: strings.Repeat("ab", 32), Size: 15},
{Path: "index.html", Digest: strings.Repeat("cd", 32), Size: 42},
}
}
writeJSON(t, w, out)
}))
defer srv.Close()
plain, _, err := runCLI(t, srv.URL, "", "deployment", "show", "dpl_1", "--project", "demo")
if err != nil {
t.Fatalf("deployment show: %v", err)
}
if strings.Contains(plain, "assets/app.js") {
t.Errorf("the manifest was printed without --files:\n%s", plain)
}
full, _, err := runCLI(t, srv.URL, "", "deployment", "show", "dpl_1", "--project", "demo", "--files")
if err != nil {
t.Fatalf("deployment show --files: %v", err)
}
if !strings.Contains(full, "assets/app.js") || !strings.Contains(full, "index.html") {
t.Errorf("--files did not list the manifest:\n%s", full)
}
}
// TestDeploymentDeleteAsksFirst. Deleting the wrong deployment is not
// recoverable from the CLI, so the prompt is the safety net and --yes is the
// documented way past it.
func TestDeploymentDeleteAsksFirst(t *testing.T) {
newServer := func(seen *[]string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*seen = append(*seen, r.Method+" "+r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
}
t.Run("declining deletes nothing", func(t *testing.T) {
var seen []string
srv := newServer(&seen)
defer srv.Close()
_, errOut, err := runCLI(t, srv.URL, "n\n", "deployment", "delete", "dpl_1", "--project", "demo")
if !errors.Is(err, cliutil.ErrAborted) {
t.Fatalf("err = %v, want it to report the abort", err)
}
if len(seen) != 0 {
t.Errorf("requests = %q, want none", seen)
}
if !strings.Contains(errOut, "dpl_1") {
t.Errorf("prompt = %q, want it to name the deployment", errOut)
}
})
t.Run("confirming deletes", func(t *testing.T) {
var seen []string
srv := newServer(&seen)
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "y\n", "deployment", "delete", "dpl_1", "--project", "demo")
if err != nil {
t.Fatalf("deployment delete: %v", err)
}
want := "DELETE " + api.PathDeployment("demo", "dpl_1")
if len(seen) != 1 || seen[0] != want {
t.Errorf("requests = %q, want [%q]", seen, want)
}
if !strings.Contains(out, "dpl_1") {
t.Errorf("output = %q, want it to confirm what was deleted", out)
}
})
t.Run("--yes does not prompt", func(t *testing.T) {
var seen []string
srv := newServer(&seen)
defer srv.Close()
// Empty stdin: without --yes this would abort rather than delete.
_, errOut, err := runCLI(t, srv.URL, "", "deployment", "delete", "dpl_1", "--project", "demo", "--yes")
if err != nil {
t.Fatalf("deployment delete --yes: %v", err)
}
if len(seen) != 1 {
t.Errorf("requests = %q, want one delete", seen)
}
if strings.Contains(errOut, "[y/N]") {
t.Errorf("stderr = %q, want no prompt", errOut)
}
})
}
// TestSystemGCPostsTheDryRunFlag: a dry run that silently ran for real is the
// worst bug this command could have, so the flag's trip to the wire is checked
// rather than assumed.
func TestSystemGCPostsTheDryRunFlag(t *testing.T) {
var gotPath, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, 256)
n, _ := r.Body.Read(body)
gotPath, gotBody = r.Method+" "+r.URL.Path, string(body[:n])
writeJSON(t, w, api.GCStats{DryRun: true, DeploymentsDeleted: 3, BlobsDeleted: 4, BytesFreed: 5120})
}))
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "", "system", "gc", "--dry-run")
if err != nil {
t.Fatalf("system gc: %v", err)
}
if want := "POST " + api.PathGC(); gotPath != want {
t.Errorf("request = %q, want %q", gotPath, want)
}
if !strings.Contains(gotBody, `"dry_run":true`) {
t.Errorf("body = %q, want dry_run set", gotBody)
}
for _, want := range []string{"dry_run", "yes", "deployments_deleted", "3", "blobs_deleted", "4"} {
if !strings.Contains(out, want) {
t.Errorf("output is missing %q:\n%s", want, out)
}
}
}
// TestSystemFsckReportsDrift — the report exists for the case where the counts
// are wrong, so the drifting digests have to reach the operator's screen.
func TestSystemFsckReportsDrift(t *testing.T) {
digest := strings.Repeat("ab", 32)
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, 256)
n, _ := r.Body.Read(body)
gotBody = string(body[:n])
writeJSON(t, w, api.FsckReport{
Blobs: 9,
DriftCount: 1,
Repaired: 1,
Drift: []api.BlobDrift{{Digest: digest, Stored: 7, Actual: 1}},
})
}))
defer srv.Close()
out, _, err := runCLI(t, srv.URL, "", "system", "fsck", "--repair")
if err != nil {
t.Fatalf("system fsck: %v", err)
}
if !strings.Contains(gotBody, `"repair":true`) {
t.Errorf("body = %q, want repair set", gotBody)
}
// Truncate spends its last column on the ellipsis, so twelve columns of
// digest are eleven characters and a marker that there is more.
if !strings.Contains(out, digest[:11]+"…") {
t.Errorf("output does not name the drifting blob:\n%s", out)
}
if !strings.Contains(out, "stored 7, actual 1") {
t.Errorf("output does not give the counts:\n%s", out)
}
}