init
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user