package commands import ( "encoding/json" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" ) func runCLIProposal(t *testing.T, dir, cliPath string, args ...string) (string, error) { cmd := exec.Command(cliPath, args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "HF_TEST_MODE=1") out, err := cmd.CombinedOutput() return string(out), err } // --- Essential subcommand tests --- func TestEssentialList_MissingProposal(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) json.NewEncoder(w).Encode([]interface{}{}) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "list", "--token", "fake") if err == nil { t.Fatalf("expected error for missing --proposal") } if !strings.Contains(out, "--proposal") { t.Errorf("expected --proposal error; got: %s", out) } } func TestEssentialCreate_MissingRequired(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) // Missing --proposal, --title, --type out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", "--proposal", "PRJ-001") if err == nil { t.Fatalf("expected error for missing required args") } if !strings.Contains(out, "usage:") { t.Errorf("expected usage message; got: %s", out) } } func TestEssentialCreate_InvalidType(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", "--proposal", "PRJ-001", "--title", "Test", "--type", "invalid-type") if err == nil { t.Fatalf("expected error for invalid type") } if !strings.Contains(out, "invalid essential type") { t.Errorf("expected invalid type error; got: %s", out) } } func TestEssentialCreate_UnknownFlag(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", "--proposal", "PRJ-001", "--title", "Test", "--type", "feature", "--unknown") if err == nil { t.Fatalf("expected error for unknown flag") } if !strings.Contains(out, "unknown flag") { t.Errorf("expected unknown flag error; got: %s", out) } } func TestEssentialUpdate_NothingToUpdate(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake", "ESS-001", "--proposal", "PRJ-001") if err == nil { t.Fatalf("expected error for nothing to update") } if !strings.Contains(out, "nothing to update") { t.Errorf("expected 'nothing to update' error; got: %s", out) } } func TestEssentialUpdate_MissingProposal(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake", "ESS-001") if err == nil { t.Fatalf("expected error for missing --proposal") } if !strings.Contains(out, "--proposal") { t.Errorf("expected --proposal error; got: %s", out) } } func TestEssentialDelete_MissingProposal(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake", "ESS-001") if err == nil { t.Fatalf("expected error for missing --proposal") } if !strings.Contains(out, "--proposal") { t.Errorf("expected --proposal error; got: %s", out) } } // --- Essential list/create JSON output --- func TestEssentialList_JSONOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/projects": w.WriteHeader(200) json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}}) case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001": w.WriteHeader(200) json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"}) case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials": w.WriteHeader(200) json.NewEncoder(w).Encode([]interface{}{ map[string]interface{}{ "id": 1, "essential_code": "ESS-001", "proposal_id": 1, "type": "feature", "title": "Add login", "created_at": "2026-03-01", }, }) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(404) } })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "essential", "list", "--token", "fake", "--proposal", "PRJ-001") if err != nil { t.Fatalf("unexpected error: %v; out=%s", err, out) } var resp interface{} if err := json.Unmarshal([]byte(out), &resp); err != nil { t.Fatalf("output is not valid JSON: %s", out) } } func TestEssentialCreate_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/projects": w.WriteHeader(200) json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}}) case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001": w.WriteHeader(200) json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"}) case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials": w.WriteHeader(200) json.NewEncoder(w).Encode(map[string]interface{}{ "id": 1, "essential_code": "ESS-001", "title": "Add login", "type": "feature", }) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(404) } })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", "--proposal", "PRJ-001", "--title", "Add login", "--type", "feature") if err != nil { t.Fatalf("unexpected error: %v; out=%s", err, out) } if !strings.Contains(out, "essential created") { t.Errorf("expected 'essential created' success message; got: %s", out) } } func TestEssentialUpdate_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/projects": w.WriteHeader(200) json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}}) case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001": w.WriteHeader(200) json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"}) case r.Method == "PATCH" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001": w.WriteHeader(200) w.Write([]byte(`{}`)) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(404) } })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake", "ESS-001", "--proposal", "PRJ-001", "--title", "Updated title") if err != nil { t.Fatalf("unexpected error: %v; out=%s", err, out) } if !strings.Contains(out, "essential updated") { t.Errorf("expected 'essential updated' success message; got: %s", out) } } func TestEssentialDelete_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/projects": w.WriteHeader(200) json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}}) case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001": w.WriteHeader(200) json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"}) case r.Method == "DELETE" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001": w.WriteHeader(200) w.Write([]byte(`{}`)) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(404) } })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake", "ESS-001", "--proposal", "PRJ-001") if err != nil { t.Fatalf("unexpected error: %v; out=%s", err, out) } if !strings.Contains(out, "essential deleted") { t.Errorf("expected 'essential deleted' success message; got: %s", out) } } // --- Proposal Accept tests --- func TestProposalAccept_MissingMilestone(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake", "PRJ-001") if err == nil { t.Fatalf("expected error for missing --milestone") } if !strings.Contains(out, "--milestone") { t.Errorf("expected --milestone error; got: %s", out) } } func TestProposalAccept_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/projects": w.WriteHeader(200) json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}}) case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001": w.WriteHeader(200) json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"}) case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/accept": var body map[string]interface{} json.NewDecoder(r.Body).Decode(&body) if body["milestone_code"] != "MS-001" { t.Errorf("expected milestone_code=MS-001; got: %v", body["milestone_code"]) } w.WriteHeader(200) json.NewEncoder(w).Encode(map[string]interface{}{ "code": "PRJ-001", "status": "Accepted", "tasks": []interface{}{}, }) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(404) } })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake", "PRJ-001", "--milestone", "MS-001") if err != nil { t.Fatalf("unexpected error: %v; out=%s", err, out) } if !strings.Contains(out, "proposal accepted") { t.Errorf("expected proposal accepted success message; got: %s", out) } } func TestProposalAccept_JSONOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) json.NewEncoder(w).Encode(map[string]interface{}{ "code": "PRJ-001", "status": "Accepted", "tasks": []interface{}{ map[string]interface{}{"code": "TASK-1", "type": "story/feature"}, }, }) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "accept", "--token", "fake", "PRJ-001", "--milestone", "MS-001") if err != nil { t.Fatalf("unexpected error: %v; out=%s", err, out) } var resp map[string]interface{} if err := json.Unmarshal([]byte(out), &resp); err != nil { t.Fatalf("output is not valid JSON: %s", out) } if resp["status"] != "Accepted" { t.Errorf("expected status=Accepted; got: %v", resp["status"]) } } // --- Story restricted tests --- func TestTaskCreate_StoryRestricted(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) // Try to create a story/feature directly out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake", "--type", "story/feature", "--title", "My story", "--project", "PRJ-001") if err == nil { t.Fatalf("expected error for restricted story creation") } if !strings.Contains(out, "restricted") && !strings.Contains(out, "proposal accept") { t.Errorf("expected restricted error mentioning proposal accept; got: %s", out) } } func TestTaskCreate_StoryTypeOnlyRestricted(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("server should not be called for restricted story type") w.WriteHeader(200) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) // Just "story" without slash should also be blocked out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake", "--type", "story", "--title", "My story", "--project", "PRJ-001") if err == nil { t.Fatalf("expected error for restricted story type") } if !strings.Contains(out, "restricted") { t.Errorf("expected restricted error; got: %s", out) } } // --- Proposal list tests --- func TestProposalList_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" { t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) } w.WriteHeader(200) json.NewEncoder(w).Encode([]interface{}{ map[string]interface{}{ "id": 1, "code": "PRJ-001", "title": "My Proposal", "status": "Open", "project_code": "PROJ-001", "created_by": "alice", "created_at": "2026-03-01", }, }) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "list", "--token", "fake", "--project", "PROJ-001") if err != nil { t.Fatalf("unexpected error: %v; out=%s", err, out) } if !strings.Contains(out, "PRJ-001") { t.Errorf("expected proposal code in output; got: %s", out) } } func TestProposalList_JSONOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" { t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) } w.WriteHeader(200) json.NewEncoder(w).Encode([]interface{}{ map[string]interface{}{ "id": 1, "code": "PRJ-001", "title": "My Proposal", "status": "Open", }, }) })) defer server.Close() tmpDir := t.TempDir() writeTestConfig(t, tmpDir, server.URL) cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "list", "--token", "fake", "--project", "PROJ-001") if err != nil { t.Fatalf("unexpected error: %v; out=%s", err, out) } var resp interface{} if err := json.Unmarshal([]byte(out), &resp); err != nil { t.Fatalf("output is not valid JSON: %s", out) } }