package console import ( "context" "encoding/json" "io" "net/http" "testing" "time" "github.com/carlosframework/platform/internal/release" ) func retentionFixture(t *testing.T, code string) (*consoleFixture, string) { t.Helper() fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { t.Fatal(err) } // A MEMBER: retention is app config, like env set (spec ยง7 ruling). if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { t.Fatal(err) } return fx, cliToken(t, fx, "alice@keymail.dev", code) } func getRetention(t *testing.T, u, bearer string) (int, []byte) { t.Helper() req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", "Bearer "+bearer) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() body, _ := io.ReadAll(res.Body) return res.StatusCode, body } func TestAPIRetentionSetWritesAuditsAndAnnounces(t *testing.T) { fx, token := retentionFixture(t, "code-ret-set") status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token, `{"keep": 50}`) if status != http.StatusOK { t.Fatalf("set = %d %s, want 200", status, out) } var res struct { Account string `json:"account"` App string `json:"app"` Keep int `json:"keep"` } if err := json.Unmarshal(out, &res); err != nil { t.Fatal(err) } if res.Account != "bab" || res.App != "titogo" || res.Keep != 50 { t.Fatalf("set body = %+v", res) } ret, err := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo") if err != nil || ret == nil || ret.Keep != 50 { t.Fatalf("record after set = %+v, %v; want keep 50", ret, err) } if ret.SetBy != "alice@keymail.dev" { t.Fatalf("SetBy = %q, want the caller's address", ret.SetBy) } if got := serial(t, fx); got != 1 { t.Fatalf("serial = %d, want 1", got) } } func TestAPIRetentionFloorIs400(t *testing.T) { fx, token := retentionFixture(t, "code-ret-floor") status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token, `{"keep": 5}`) if status != http.StatusBadRequest { t.Fatalf("keep 5 = %d %s, want 400", status, out) } if ret, _ := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo"); ret != nil { t.Fatal("a refused set still wrote a record") } } func TestAPIRetentionOffClearsTheRecord(t *testing.T) { fx, token := retentionFixture(t, "code-ret-off") if err := release.SetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo", 50, "seed", time.Now()); err != nil { t.Fatal(err) } status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token, `{"off": true}`) if status != http.StatusOK { t.Fatalf("off = %d %s, want 200", status, out) } if ret, _ := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo"); ret != nil { t.Fatalf("record after off = %+v, want nil", ret) } } func TestAPIRetentionDryRunWritesNothing(t *testing.T) { fx, token := retentionFixture(t, "code-ret-dry") status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token, `{"keep": 20, "dry_run": true}`) if status != http.StatusOK { t.Fatalf("dry-run = %d %s, want 200", status, out) } var res struct { DryRun bool `json:"dry_run"` EligibleVersions []string `json:"eligible_versions"` } if err := json.Unmarshal(out, &res); err != nil { t.Fatal(err) } if !res.DryRun { t.Fatalf("dry-run body = %s, want dry_run true", out) } if ret, _ := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo"); ret != nil { t.Fatal("dry-run wrote a record") } if got := serial(t, fx); got != 0 { t.Fatalf("serial after dry-run = %d, want 0 (nothing to announce)", got) } } func TestAPIRetentionGetShowsPolicyAndOffState(t *testing.T) { fx, token := retentionFixture(t, "code-ret-get") status, out := getRetention(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token) if status != http.StatusOK { t.Fatalf("get (off) = %d %s, want 200", status, out) } var res struct { Keep int `json:"keep"` } if err := json.Unmarshal(out, &res); err != nil { t.Fatal(err) } if res.Keep != 0 { t.Fatalf("keep with no policy = %d, want 0", res.Keep) } if err := release.SetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo", 25, "seed", time.Now()); err != nil { t.Fatal(err) } status, out = getRetention(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token) if status != http.StatusOK { t.Fatalf("get = %d %s, want 200", status, out) } if err := json.Unmarshal(out, &res); err != nil { t.Fatal(err) } if res.Keep != 25 { t.Fatalf("keep = %d, want 25", res.Keep) } } func TestAPIRetentionUnknownAppIsPlain404(t *testing.T) { fx, token := retentionFixture(t, "code-ret-404") status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/nothere/retention", token, `{"keep": 50}`) if status != http.StatusNotFound { t.Fatalf("unknown app = %d %s, want the plain 404", status, out) } }