package api import ( "net/http" "testing" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/workload/plugin" ) // createImageWorkload creates an image-source workload through the API so // source_kind is persisted exactly as production does, returning its id. func createImageWorkload(t *testing.T, e *apiTestEnv, name string) string { t.Helper() resp := e.do(t, http.MethodPost, "/api/workloads", pluginWorkloadRequest{ Name: name, SourceKind: "image", SourceConfig: validImageSourceConfig(), }) if resp.StatusCode != http.StatusCreated { _ = decodeEnvelope(t, resp, nil) t.Fatalf("create workload: status %d", resp.StatusCode) } var got plugin.Workload if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("create workload envelope error: %q", errMsg) } return got.ID } func TestListWorkloadDeploys_ComputesRollbackable(t *testing.T) { e := newAPITestEnv(t) id := createImageWorkload(t, e, "app") // success + reference + image => rollbackable e.store.InsertDeployHistory(store.DeployHistoryEntry{ WorkloadID: id, SourceKind: "image", Reference: "v1", Outcome: "success", }) // failure => not rollbackable e.store.InsertDeployHistory(store.DeployHistoryEntry{ WorkloadID: id, SourceKind: "image", Reference: "v2", Outcome: "failure", }) // success but empty reference => not rollbackable e.store.InsertDeployHistory(store.DeployHistoryEntry{ WorkloadID: id, SourceKind: "image", Reference: "", Outcome: "success", }) resp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/deploys", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var rows []store.DeployHistoryEntry if errMsg := decodeEnvelope(t, resp, &rows); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if len(rows) != 3 { t.Fatalf("expected 3 rows, got %d", len(rows)) } // Newest-first: empty-ref success, failure, then v1 success. if !rows[2].Rollbackable { t.Fatalf("v1 success row should be rollbackable: %+v", rows[2]) } if rows[1].Rollbackable || rows[0].Rollbackable { t.Fatalf("failure / empty-ref rows must not be rollbackable") } } func TestRollback_HappyPath_DispatchesRollbackIntent(t *testing.T) { e := newAPITestEnv(t) id := createImageWorkload(t, e, "app") entry, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{ WorkloadID: id, SourceKind: "image", Reference: "v1", Outcome: "success", }) before := e.dispatcher.deployCount.Load() resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/rollback", map[string]any{"deploy_id": entry.ID}) if resp.StatusCode != http.StatusAccepted { errMsg := decodeEnvelope(t, resp, nil) t.Fatalf("status = %d, want 202 (err=%q)", resp.StatusCode, errMsg) } if got := e.dispatcher.deployCount.Load(); got != before+1 { t.Fatalf("expected one dispatch, got delta %d", got-before) } intent := e.dispatcher.lastIntent.Load() if intent == nil || intent.Reason != "rollback" || intent.Reference != "v1" { t.Fatalf("expected rollback intent for v1, got %+v", intent) } } func TestRollback_Guards(t *testing.T) { e := newAPITestEnv(t) imageID := createImageWorkload(t, e, "img") otherID := createImageWorkload(t, e, "other") success, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{ WorkloadID: imageID, SourceKind: "image", Reference: "v1", Outcome: "success", }) failed, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{ WorkloadID: imageID, SourceKind: "image", Reference: "v2", Outcome: "failure", }) otherWL, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{ WorkloadID: otherID, SourceKind: "image", Reference: "v1", Outcome: "success", }) cases := []struct { name string workload string body any wantCode int }{ {"missing deploy_id", imageID, map[string]any{}, http.StatusBadRequest}, {"zero deploy_id", imageID, map[string]any{"deploy_id": 0}, http.StatusBadRequest}, {"unknown deploy_id", imageID, map[string]any{"deploy_id": 999999}, http.StatusNotFound}, {"unknown workload", "nope", map[string]any{"deploy_id": success.ID}, http.StatusNotFound}, {"failed deploy", imageID, map[string]any{"deploy_id": failed.ID}, http.StatusBadRequest}, {"cross-workload entry", imageID, map[string]any{"deploy_id": otherWL.ID}, http.StatusBadRequest}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { resp := e.do(t, http.MethodPost, "/api/workloads/"+c.workload+"/rollback", c.body) if resp.StatusCode != c.wantCode { errMsg := decodeEnvelope(t, resp, nil) t.Fatalf("status = %d, want %d (err=%q)", resp.StatusCode, c.wantCode, errMsg) } }) } }