| 1 | // Tests for the `carlos instances enable` core — flag parsing/validation and |
| 2 | // printing live in instances_cli.go's runInstancesEnable, which os.Exit()s on |
| 3 | // a bad invocation via log.Fatal and so isn't itself a good unit-test target |
| 4 | // (no established env-verb argv test harness exists in this package — |
| 5 | // appconfig_cli.go, the pattern this verb follows, has none either). What's |
| 6 | // tested directly instead is instancesEnable, the core PutManifest wrapper, |
| 7 | // and parseSeed, the -seed flag's whitespace-split helper. |
| 8 | package main |
| 9 | |
| 10 | import ( |
| 11 | "context" |
| 12 | "net/http" |
| 13 | "net/http/httptest" |
| 14 | "testing" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/provision" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | func TestInstanceDeleteCarriesActivityMessageHeader(t *testing.T) { |
| 21 | var gotMessage string |
| 22 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 23 | gotMessage = r.Header.Get("X-Carlos-Message") |
| 24 | w.WriteHeader(http.StatusOK) |
| 25 | })) |
| 26 | defer ts.Close() |
| 27 | |
| 28 | err := newTestSession(ts.URL).instanceDelete(context.Background(), apiAcct{Seg: "bab"}, |
| 29 | "titogo", "web.example.test", "retire the old web copy") |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | if gotMessage != "retire the old web copy" { |
| 34 | t.Fatalf("message header = %q", gotMessage) |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | func TestInstancesEnableWritesManifest(t *testing.T) { |
| 39 | ctx := context.Background() |
| 40 | s := release.NewDirStore(t.TempDir()) |
| 41 | m := provision.Manifest{ |
| 42 | Schema: 1, App: "tito", Domain: "go.tito.io", Pool: "tito", Channel: "stable", |
| 43 | Regions: []string{"ie"}, Seed: []string{"seed", "-v"}, |
| 44 | } |
| 45 | |
| 46 | if err := instancesEnable(ctx, s, "bad", "tito", m); err != nil { |
| 47 | t.Fatalf("instancesEnable: %v", err) |
| 48 | } |
| 49 | |
| 50 | got, err := provision.GetManifest(ctx, s, "bad", "tito") |
| 51 | if err != nil { |
| 52 | t.Fatalf("GetManifest: %v", err) |
| 53 | } |
| 54 | if got.App != m.App || got.Domain != m.Domain || got.Pool != m.Pool || |
| 55 | got.Channel != m.Channel || len(got.Regions) != 1 || got.Regions[0] != "ie" || |
| 56 | len(got.Seed) != 2 || got.Seed[0] != "seed" || got.Seed[1] != "-v" { |
| 57 | t.Errorf("GetManifest after instancesEnable mismatch: got %+v, want %+v", got, m) |
| 58 | } |
| 59 | |
| 60 | // Re-running with a changed pool must update the manifest in place — a |
| 61 | // CAS upsert, not a create-only write (the "carlos env set" pattern |
| 62 | // spec §3.1 names). |
| 63 | m.Pool = "other" |
| 64 | if err := instancesEnable(ctx, s, "bad", "tito", m); err != nil { |
| 65 | t.Fatalf("instancesEnable (update): %v", err) |
| 66 | } |
| 67 | got, err = provision.GetManifest(ctx, s, "bad", "tito") |
| 68 | if err != nil { |
| 69 | t.Fatalf("GetManifest after update: %v", err) |
| 70 | } |
| 71 | if got.Pool != "other" { |
| 72 | t.Errorf("Pool after update = %q, want %q", got.Pool, "other") |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // TestUpstreamsCell pins the LIST column's rendering (Task 8): "up/total" |
| 77 | // when the record carries a joined upstream table, and the literal "-" the |
| 78 | // task brief specifies for the absent case — deliberately not HEALTH's em |
| 79 | // dash "—", since an absent upstream join means "not a multi-upstream |
| 80 | // record" (the overwhelming majority), not "an old console". |
| 81 | func TestUpstreamsCell(t *testing.T) { |
| 82 | cases := []struct { |
| 83 | name string |
| 84 | ups []apiUpstreamState |
| 85 | want string |
| 86 | }{ |
| 87 | {"nil", nil, "-"}, |
| 88 | {"empty", []apiUpstreamState{}, "-"}, |
| 89 | {"all up", []apiUpstreamState{{Addr: "a:1", Up: true}, {Addr: "b:1", Up: true}}, "2/2"}, |
| 90 | {"one down", []apiUpstreamState{{Addr: "a:1", Up: true}, {Addr: "b:1", Up: false}, {Addr: "c:1", Up: true}}, "2/3"}, |
| 91 | {"all down", []apiUpstreamState{{Addr: "a:1", Up: false}}, "0/1"}, |
| 92 | } |
| 93 | for _, c := range cases { |
| 94 | if got := upstreamsCell(c.ups); got != c.want { |
| 95 | t.Errorf("upstreamsCell(%q) = %q, want %q", c.name, got, c.want) |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | // TestParseAddrList pins the given-vs-absent split -addr/-addrs share |
| 101 | // (Task 8): a flag never named on the command line must return nil (the |
| 102 | // wire's "leave/default"), never mistaken for the empty list an explicitly |
| 103 | // blank value produces. |
| 104 | func TestParseAddrList(t *testing.T) { |
| 105 | if got := parseAddrList("anything", false); got != nil { |
| 106 | t.Errorf("parseAddrList(_, given=false) = %v, want nil", got) |
| 107 | } |
| 108 | if got := parseAddrList("", true); got != nil { |
| 109 | t.Errorf("parseAddrList(\"\", given=true) = %v, want nil (no elements)", got) |
| 110 | } |
| 111 | if got := parseAddrList(" , ", true); got != nil { |
| 112 | t.Errorf("parseAddrList(whitespace-only, given=true) = %v, want nil", got) |
| 113 | } |
| 114 | got := parseAddrList(" a:1 , b:2 ,,c:3", true) |
| 115 | want := []string{"a:1", "b:2", "c:3"} |
| 116 | if len(got) != len(want) { |
| 117 | t.Fatalf("parseAddrList = %v, want %v", got, want) |
| 118 | } |
| 119 | for i := range want { |
| 120 | if got[i] != want[i] { |
| 121 | t.Fatalf("parseAddrList = %v, want %v", got, want) |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // TestParseProbeFlags pins the same given-vs-absent split for |
| 127 | // -health-path/-health-status: nil unless at least one was named, so a |
| 128 | // caller touching neither never sends a "probe" key at all — the door's own |
| 129 | // "leave the probe alone" contract (apiinstances.go). |
| 130 | func TestParseProbeFlags(t *testing.T) { |
| 131 | if got := parseProbeFlags("", 0, false, false); got != nil { |
| 132 | t.Errorf("parseProbeFlags(neither given) = %+v, want nil", got) |
| 133 | } |
| 134 | got := parseProbeFlags("/healthz", 0, true, false) |
| 135 | if got == nil || got.Path != "/healthz" || got.Status != 0 { |
| 136 | t.Errorf("parseProbeFlags(path only) = %+v, want {Path: /healthz, Status: 0}", got) |
| 137 | } |
| 138 | got = parseProbeFlags("", 204, false, true) |
| 139 | if got == nil || got.Path != "" || got.Status != 204 { |
| 140 | t.Errorf("parseProbeFlags(status only) = %+v, want {Path: \"\", Status: 204}", got) |
| 141 | } |
| 142 | got = parseProbeFlags(" /up ", 200, true, true) |
| 143 | if got == nil || got.Path != "/up" || got.Status != 200 { |
| 144 | t.Errorf("parseProbeFlags(both) = %+v, want {Path: /up, Status: 200}", got) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestParseSeed(t *testing.T) { |
| 149 | cases := []struct { |
| 150 | in string |
| 151 | want []string |
| 152 | }{ |
| 153 | {"", nil}, |
| 154 | {"seed", []string{"seed"}}, |
| 155 | {"seed -v", []string{"seed", "-v"}}, |
| 156 | {" seed -v ", []string{"seed", "-v"}}, |
| 157 | } |
| 158 | for _, c := range cases { |
| 159 | got := parseSeed(c.in) |
| 160 | if len(got) != len(c.want) { |
| 161 | t.Errorf("parseSeed(%q) = %v, want %v", c.in, got, c.want) |
| 162 | continue |
| 163 | } |
| 164 | for i := range got { |
| 165 | if got[i] != c.want[i] { |
| 166 | t.Errorf("parseSeed(%q) = %v, want %v", c.in, got, c.want) |
| 167 | break |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |