Files
HarborForge.Cli/internal/commands/proposal_test.go

532 lines
17 KiB
Go

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)
}
}