From d569a4dea9f43fecd63d56b4d828a8dec419f7c6 Mon Sep 17 00:00:00 2001 From: Elias Kohout Date: Sun, 29 Mar 2026 23:56:43 +0200 Subject: [PATCH] test: add e2e test suite and fix namespace/mention/assignee flags --- README.md | 2 +- cmd/add.go | 4 +- cmd/root.go | 42 ++++++++--- cmd/update.go | 4 +- e2e_test.go | 198 +++++++++++++++++++++++++++++++++++++++++++++++++ models/node.go | 2 +- 6 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 e2e_test.go diff --git a/README.md b/README.md index 3f5fc0e..a2a6141 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ Relations connect nodes together: | `subtask` | issue → issue | Shows as tree under parent | | `related` | any ↔ any | Shown in related section | | `assignee` | issue → user | Adds to user's inbox | +| `in_namespace`| any → namespace | Groups nodes by project or team | ```bash # Create subtask @@ -189,7 +190,6 @@ ax add "Task" --type issue --status open --prio high | `_type` | `issue`, `note`, `user`, `namespace` | Yes (default: `issue`) | | `_status` | `open`, `done` | No | | `_prio` | `high`, `medium`, `low` | No | -| `_namespace` | any string | Yes (default: current user) | ## Mentions and Inbox diff --git a/cmd/add.go b/cmd/add.go index 09dcaf4..b5a67d6 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -24,8 +24,8 @@ var addCmd = &cobra.Command{ } // default relations - if !slices.ContainsFunc(cRels, func(e string) bool { return strings.HasPrefix(e, "_namespace::") }) { - cRels = append(cRels, "_namespace::"+cfg.GetUser()) + if !slices.ContainsFunc(cRels, func(e string) bool { return strings.HasPrefix(e, "in_namespace:") }) { + cRels = append(cRels, "in_namespace:"+cfg.GetUser()) } // parse relations diff --git a/cmd/root.go b/cmd/root.go index ef14a86..1f1be53 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -36,6 +36,8 @@ func addPropertyFlags(cmd *cobra.Command) { cmd.Flags().String("status", "", "node status") cmd.Flags().String("prio", "", "node priority") cmd.Flags().String("namespace", "", "node namespace") + cmd.Flags().String("assignee", "", "node assignee") + cmd.Flags().String("mention", "", "node mention") } func registerAliasCommands() { @@ -78,24 +80,44 @@ func registerAliasCommands() { } func transformArgs(args []string) []string { - aliases := map[string]string{ - "--status": "_status", - "--prio": "_prio", - "--type": "_type", - "--namespace": "_namespace", + tagAliases := map[string]string{ + "--status": "_status", + "--prio": "_prio", + "--type": "_type", + } + relAliases := map[string]string{ + "--namespace": "in_namespace", + "--assignee": "assignee", + "--mention": "mentions", } result := []string{} for i := 0; i < len(args); i++ { if idx := strings.Index(args[i], "="); idx != -1 { - if prop, ok := aliases[args[i][:idx]]; ok { - result = append(result, "--tag", prop+"::"+args[i][idx+1:]) + flag := args[i][:idx] + val := args[i][idx+1:] + if prop, ok := tagAliases[flag]; ok { + result = append(result, "--tag", prop+"::"+val) + continue + } + if prop, ok := relAliases[flag]; ok { + result = append(result, "--rel", prop+":"+val) continue } } - if prop, ok := aliases[args[i]]; ok && i+1 < len(args) { - result, i = append(result, "--tag", prop+"::"+args[i+1]), i+1 - continue + + flag := args[i] + if i+1 < len(args) { + if prop, ok := tagAliases[flag]; ok { + result = append(result, "--tag", prop+"::"+args[i+1]) + i++ + continue + } + if prop, ok := relAliases[flag]; ok { + result = append(result, "--rel", prop+":"+args[i+1]) + i++ + continue + } } result = append(result, args[i]) } diff --git a/cmd/update.go b/cmd/update.go index 0a26d74..eb70ff4 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -55,11 +55,11 @@ var updateCmd = &cobra.Command{ ok, blockers, err := svc.CanClose(args[0]) if err != nil { fmt.Fprintln(os.Stderr, "failed to check blockers:", err) - return + os.Exit(1) } if !ok { fmt.Fprintf(os.Stderr, "cannot close: blocked by %v\n", blockers) - return + os.Exit(1) } } diff --git a/e2e_test.go b/e2e_test.go new file mode 100644 index 0000000..71dafb1 --- /dev/null +++ b/e2e_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +type NodeResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Tags []string `json:"tags"` + Relations map[string][]string `json:"relations"` +} + +func runAx(t *testing.T, dir string, args ...string) (string, error) { + cmd := exec.Command("./ax", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + return string(out), err +} + +func parseJSON(t *testing.T, out string) NodeResponse { + var node NodeResponse + err := json.Unmarshal([]byte(out), &node) + if err != nil { + t.Fatalf("failed to parse JSON output: %v\nOutput: %s", err, out) + } + return node +} + +func TestE2E(t *testing.T) { + // Build the binary first + cmd := exec.Command("go", "build", "-o", "ax", ".") + err := cmd.Run() + if err != nil { + t.Fatalf("failed to build: %v", err) + } + defer os.Remove("./ax") + + // Create a temp directory for the test + tmpDir, err := os.MkdirTemp("", "ax-e2e-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Copy binary to temp dir + binaryPath := filepath.Join(tmpDir, "ax") + cmd = exec.Command("cp", "./ax", binaryPath) + err = cmd.Run() + if err != nil { + t.Fatalf("failed to copy binary: %v", err) + } + + // 1. Initialize DB + out, err := runAx(t, tmpDir, "init") + if err != nil { + t.Fatalf("ax init failed: %v\n%s", err, out) + } + + // Set test user + os.Setenv("AX_USER", "testuser") + defer os.Unsetenv("AX_USER") + + // 2. Add an issue + out, err = runAx(t, tmpDir, "add", "Test Issue 1", "--namespace", "testproj", "--prio", "high", "--json") + if err != nil { + t.Fatalf("ax add failed: %v\n%s", err, out) + } + node1 := parseJSON(t, out) + + if node1.Title != "Test Issue 1" { + t.Errorf("Expected title 'Test Issue 1', got '%s'", node1.Title) + } + + hasHighPrio := false + for _, tag := range node1.Tags { + if tag == "_prio::high" { + hasHighPrio = true + } + } + if !hasHighPrio { + t.Errorf("Expected tag '_prio::high', got %v", node1.Tags) + } + + // Note: in_namespace relation points to a node ID, not the string "testproj". + // We'll verify it by listing the namespace. + + // 3. Add a second issue (the blocker) + out, err = runAx(t, tmpDir, "add", "Test Issue 2", "--json") + if err != nil { + t.Fatalf("ax add failed: %v\n%s", err, out) + } + node2 := parseJSON(t, out) + + // Make node1 blocked by node2 + out, err = runAx(t, tmpDir, "update", node1.ID, "--rel", "blocks:"+node2.ID, "--json") + if err != nil { + t.Fatalf("ax update failed: %v\n%s", err, out) + } + + // 4. Try to close the blocked issue (should fail) + out, err = runAx(t, tmpDir, "update", node1.ID, "--status", "done") + if err == nil { + t.Errorf("Expected closing blocked issue to fail, but it succeeded") + } + if !strings.Contains(out, "cannot close: blocked by") { + t.Errorf("Expected blocked error message, got: %s", out) + } + + // 5. Close the blocker + out, err = runAx(t, tmpDir, "update", node2.ID, "--status", "done", "--json") + if err != nil { + t.Fatalf("ax update failed: %v\n%s", err, out) + } + + // 6. Close the original issue (should succeed now) + out, err = runAx(t, tmpDir, "update", node1.ID, "--status", "done", "--json") + if err != nil { + t.Fatalf("ax update failed: %v\n%s", err, out) + } + + // 7. Update assignee + out, err = runAx(t, tmpDir, "update", node1.ID, "--assignee", "bob", "--json") + if err != nil { + t.Fatalf("ax update failed: %v\n%s", err, out) + } + + // List by assignee to verify + out, err = runAx(t, tmpDir, "list", "--assignee", "bob", "--json") + if err != nil { + t.Fatalf("ax list assignee failed: %v\n%s", err, out) + } + var assigneeNodes []NodeResponse + err = json.Unmarshal([]byte(out), &assigneeNodes) + if err != nil { + t.Fatalf("failed to parse list JSON: %v\n%s", err, out) + } + foundAssigned := false + for _, n := range assigneeNodes { + if n.ID == node1.ID { + foundAssigned = true + } + } + if !foundAssigned { + t.Errorf("Expected to find node %s assigned to bob", node1.ID) + } + + // 8. List by namespace + out, err = runAx(t, tmpDir, "list", "--namespace", "testproj", "--json") + if err != nil { + t.Fatalf("ax list failed: %v\n%s", err, out) + } + + // Should be an array of nodes + var nodes []NodeResponse + err = json.Unmarshal([]byte(out), &nodes) + if err != nil { + t.Fatalf("failed to parse list JSON: %v\n%s", err, out) + } + + foundNamespace := false + for _, n := range nodes { + if n.ID == node1.ID { + foundNamespace = true + } + } + if !foundNamespace { + t.Errorf("Expected node %s in namespace 'testproj', but wasn't found in list output: %v", node1.ID, nodes) + } + + // 9. Mention parsing + out, err = runAx(t, tmpDir, "add", "Hello @alice", "--content", "Please see this @alice.", "--json") + if err != nil { + t.Fatalf("ax add mention failed: %v\n%s", err, out) + } + + // List mention + out, err = runAx(t, tmpDir, "list", "--mention", "alice", "--json") + if err != nil { + t.Fatalf("ax list mention failed: %v\n%s", err, out) + } + + err = json.Unmarshal([]byte(out), &nodes) + if err != nil { + t.Fatalf("failed to parse mention list JSON: %v\n%s", err, out) + } + if len(nodes) == 0 { + t.Errorf("Expected at least 1 node in alice's inbox") + } + + // Test completed successfully +} diff --git a/models/node.go b/models/node.go index 0d0a6f5..7b136ef 100644 --- a/models/node.go +++ b/models/node.go @@ -134,7 +134,7 @@ func (n *Node) AddRelation(relType RelType, target string) { if n.relations == nil { n.relations = make(map[string][]string) } - if relType == RelAssignee || relType == RelCreated { + if relType == RelAssignee || relType == RelCreated || relType == RelInNamespace { n.relations[string(relType)] = []string{target} return }