| 1 | // Tests for the `carlos sidecars` core — flag parsing/validation and |
| 2 | // printing live in sidecars_cli.go's run* functions, which os.Exit() on a |
| 3 | // bad invocation via log.Fatal and so aren't themselves a good unit-test |
| 4 | // target (instances_cli_test.go's own doc comment: no argv test harness |
| 5 | // exists in this package for os.Exit paths). What's tested directly |
| 6 | // instead is sidecarsEnable, the core PutSidecarManifest wrapper; |
| 7 | // parseArgv, the -args flag's whitespace-split helper; parseDurationSeconds, |
| 8 | // the -idle/-max-run duration-to-seconds conversion; the two small render |
| 9 | // helpers `ls`'s VERSION and PLACEMENT columns go through; and |
| 10 | // TestSidecarsListTruncatedNotice, which drives the real runSidecarsList |
| 11 | // against a fake console (apimode_test.go's newTestSession/loginTo/ |
| 12 | // apiTestAccount verb-level fixtures) to pin the truncation honesty notice |
| 13 | // added in review round 1. |
| 14 | package main |
| 15 | |
| 16 | import ( |
| 17 | "context" |
| 18 | "encoding/json" |
| 19 | "net/http" |
| 20 | "net/http/httptest" |
| 21 | "strings" |
| 22 | "testing" |
| 23 | |
| 24 | "github.com/carlosframework/platform/internal/provision" |
| 25 | "github.com/carlosframework/platform/internal/release" |
| 26 | ) |
| 27 | |
| 28 | func TestSidecarsEnableWritesManifest(t *testing.T) { |
| 29 | ctx := context.Background() |
| 30 | s := release.NewDirStore(t.TempDir()) |
| 31 | no := false |
| 32 | m := provision.SidecarManifest{ |
| 33 | Schema: 1, App: "titogo", Pool: "tito", Channel: "stable", |
| 34 | Placement: provision.PlacementNoInbound, Regions: []string{"ie"}, |
| 35 | Args: []string{"sidecar", "run"}, Hibernate: &no, |
| 36 | IdleSeconds: 300, MaxRunSeconds: 3600, |
| 37 | } |
| 38 | |
| 39 | if err := sidecarsEnable(ctx, s, "bad", "titogo", m); err != nil { |
| 40 | t.Fatalf("sidecarsEnable: %v", err) |
| 41 | } |
| 42 | |
| 43 | got, err := provision.GetSidecarManifest(ctx, s, "bad", "titogo") |
| 44 | if err != nil { |
| 45 | t.Fatalf("GetSidecarManifest: %v", err) |
| 46 | } |
| 47 | if got.App != m.App || got.Pool != m.Pool || got.Channel != m.Channel || |
| 48 | got.Placement != m.Placement || len(got.Regions) != 1 || got.Regions[0] != "ie" || |
| 49 | len(got.Args) != 2 || got.Args[0] != "sidecar" || got.Args[1] != "run" || |
| 50 | got.Hibernate == nil || *got.Hibernate != false || |
| 51 | got.IdleSeconds != 300 || got.MaxRunSeconds != 3600 { |
| 52 | t.Errorf("GetSidecarManifest after sidecarsEnable mismatch: got %+v, want %+v", got, m) |
| 53 | } |
| 54 | |
| 55 | // Re-running with a changed pool must update the manifest in place — a |
| 56 | // CAS upsert, not a create-only write, exactly as instancesEnable's own |
| 57 | // re-run test pins (instances_cli_test.go). |
| 58 | m.Pool = "other" |
| 59 | if err := sidecarsEnable(ctx, s, "bad", "titogo", m); err != nil { |
| 60 | t.Fatalf("sidecarsEnable (update): %v", err) |
| 61 | } |
| 62 | got, err = provision.GetSidecarManifest(ctx, s, "bad", "titogo") |
| 63 | if err != nil { |
| 64 | t.Fatalf("GetSidecarManifest after update: %v", err) |
| 65 | } |
| 66 | if got.Pool != "other" { |
| 67 | t.Errorf("Pool after update = %q, want %q", got.Pool, "other") |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestParseArgv(t *testing.T) { |
| 72 | cases := []struct { |
| 73 | in string |
| 74 | want []string |
| 75 | }{ |
| 76 | {"", nil}, |
| 77 | {"sidecar", []string{"sidecar"}}, |
| 78 | {"sidecar run", []string{"sidecar", "run"}}, |
| 79 | {" sidecar run ", []string{"sidecar", "run"}}, |
| 80 | } |
| 81 | for _, c := range cases { |
| 82 | got := parseArgv(c.in) |
| 83 | if len(got) != len(c.want) { |
| 84 | t.Errorf("parseArgv(%q) = %v, want %v", c.in, got, c.want) |
| 85 | continue |
| 86 | } |
| 87 | for i := range got { |
| 88 | if got[i] != c.want[i] { |
| 89 | t.Errorf("parseArgv(%q) = %v, want %v", c.in, got, c.want) |
| 90 | break |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // TestParseDurationSeconds pins the wire's own contract (design §4.1): |
| 97 | // idle/max-run cross as whole SECONDS, never a duration string — "5m" must |
| 98 | // become exactly 300, "1h" exactly 3600. |
| 99 | func TestParseDurationSeconds(t *testing.T) { |
| 100 | cases := []struct { |
| 101 | in string |
| 102 | want int |
| 103 | wantErr bool |
| 104 | }{ |
| 105 | {"5m", 300, false}, |
| 106 | {"1h", 3600, false}, |
| 107 | {"0s", 0, false}, |
| 108 | {"30s", 30, false}, |
| 109 | {"-5m", 0, true}, |
| 110 | {"banana", 0, true}, |
| 111 | } |
| 112 | for _, c := range cases { |
| 113 | got, err := parseDurationSeconds(c.in) |
| 114 | if c.wantErr { |
| 115 | if err == nil { |
| 116 | t.Errorf("parseDurationSeconds(%q) = %d, <nil>, want an error", c.in, got) |
| 117 | } |
| 118 | continue |
| 119 | } |
| 120 | if err != nil { |
| 121 | t.Errorf("parseDurationSeconds(%q) unexpected error: %v", c.in, err) |
| 122 | continue |
| 123 | } |
| 124 | if got != c.want { |
| 125 | t.Errorf("parseDurationSeconds(%q) = %d, want %d", c.in, got, c.want) |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // TestSidecarVersionCell pins `ls`'s honesty rule directly (design §9.2, |
| 131 | // binding on this task): a sidecar that has never reported a version must |
| 132 | // render "unknown", never the record's own channel or anything else that |
| 133 | // would read as a current, confirmed version. |
| 134 | func TestSidecarVersionCell(t *testing.T) { |
| 135 | cases := []struct{ in, want string }{ |
| 136 | {"", "unknown"}, |
| 137 | {"abc1234", "abc1234"}, |
| 138 | } |
| 139 | for _, c := range cases { |
| 140 | if got := sidecarVersionCell(c.in); got != c.want { |
| 141 | t.Errorf("sidecarVersionCell(%q) = %q, want %q", c.in, got, c.want) |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | func TestSidecarPlacementCell(t *testing.T) { |
| 147 | cases := []struct{ in, want string }{ |
| 148 | {"", provision.PlacementAny}, |
| 149 | {provision.PlacementNoInbound, provision.PlacementNoInbound}, |
| 150 | {provision.PlacementAny, provision.PlacementAny}, |
| 151 | } |
| 152 | for _, c := range cases { |
| 153 | if got := sidecarPlacementCell(c.in); got != c.want { |
| 154 | t.Errorf("sidecarPlacementCell(%q) = %q, want %q", c.in, got, c.want) |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // TestSidecarsListTruncatedNotice pins the truncation honesty rule added in |
| 160 | // Task 14 review round 1: the console caps one `ls` answer at |
| 161 | // sidecarListMax (500, internal/console/apisidecars.go) and reports it via |
| 162 | // the response's `truncated` flag; a caller that prints the records and |
| 163 | // drops that flag on the floor renders a list that quietly stops at a |
| 164 | // prefix and says nothing — the same overclaim VERSION's "unknown" exists |
| 165 | // to prevent, and not hypothetical for the motivating customer (Eleven: a |
| 166 | // bot per enrolled member, so exceeding 500 is growth, not misuse). |
| 167 | // |
| 168 | // This drives the real runSidecarsList — not just the client method — |
| 169 | // against a fake console built the way apimode_test.go's other verb-level |
| 170 | // tests are (newTestSession/loginTo/apiTestAccount), asserting the notice |
| 171 | // appears for a truncated answer and stays silent for a whole one. Those |
| 172 | // two cases are the mutation check the brief asked for: a change that |
| 173 | // ignores the flag entirely (always prints it, or never does) fails |
| 174 | // exactly one of the two subtests. |
| 175 | func TestSidecarsListTruncatedNotice(t *testing.T) { |
| 176 | for _, tc := range []struct { |
| 177 | name string |
| 178 | truncated bool |
| 179 | }{ |
| 180 | {"truncated", true}, |
| 181 | {"not truncated", false}, |
| 182 | } { |
| 183 | t.Run(tc.name, func(t *testing.T) { |
| 184 | useTempCredentials(t) |
| 185 | clearDeploymentEnv(t) |
| 186 | checkNoRegistry := requireNoRegistryTouch(t) |
| 187 | |
| 188 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 189 | json.NewEncoder(w).Encode(map[string]any{ |
| 190 | "sidecars": []map[string]any{ |
| 191 | {"name": "bot-acme", "host": "bot-acme.titogo.bab.sidecar.internal", "channel": "stable"}, |
| 192 | }, |
| 193 | "truncated": tc.truncated, |
| 194 | }) |
| 195 | })) |
| 196 | defer ts.Close() |
| 197 | loginTo(t, ts.URL, "vicky@keymail.dev") |
| 198 | |
| 199 | buf := captureStdout(t) |
| 200 | runSidecarsList([]string{"-app", "titogo", "-account", apiTestAccount}) |
| 201 | |
| 202 | out := buf.String() |
| 203 | const notice = "more exist" |
| 204 | if tc.truncated && !strings.Contains(out, notice) { |
| 205 | t.Fatalf("ls stdout = %q, want a truncation notice containing %q", out, notice) |
| 206 | } |
| 207 | if !tc.truncated && strings.Contains(out, notice) { |
| 208 | t.Fatalf("ls stdout = %q, must NOT claim more exist for a whole (untruncated) answer", out) |
| 209 | } |
| 210 | checkNoRegistry() |
| 211 | }) |
| 212 | } |
| 213 | } |
| 214 | |