From 4404518f5010542642869a725e22baddd24d458f Mon Sep 17 00:00:00 2001 From: Elias Kohout Date: Tue, 31 Mar 2026 22:36:49 +0200 Subject: [PATCH] test: add e2e test suite and fix namespace/mention/assignee flags Co-Authored-By: Claude Sonnet 4.6 --- README.md | 35 +- cmd/alias.go | 11 +- cmd/del.go | 4 +- cmd/root.go | 18 +- cmd/show.go | 7 +- e2e_test.go | 883 +++++++++++++++++++++++++++++------ service/node_service_impl.go | 13 +- 7 files changed, 794 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index a2a6141..2ae224c 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,9 @@ Update a node. | `--title` | New title | | `--status` | New status | | `--prio` | New priority | +| `--type` | New type | +| `--namespace` | New namespace | +| `--assignee` | New assignee | | `--due` | New due date | | `--clear-due` | Clear due date | | `--content` | New content | @@ -103,6 +106,7 @@ Query and list nodes. | `--namespace` | Filter by namespace | | `--tag` | Filter by tag (repeatable) | | `--assignee` | Filter by assignee | +| `--mention` | Filter by mention | ### `ax edit ` @@ -128,9 +132,8 @@ ax alias del mywork # delete alias | Alias | Command | Description | |-------|---------|-------------| -| `mine` | `list --assignee $me --tag _status::open` | Show open tasks assigned to you | -| `due` | `list --tag _status::open --tag _due` | Show open tasks with due dates | -| `new` | `add $@` | Create a new task | +| `mine` | `list --assignee $me --type issue --status open` | Show open issues assigned to you | +| `due` | `list --type issue --status open` | Show open issues | | `inbox` | `list --mention $me` | Show your inbox | **Alias argument expansion:** @@ -151,23 +154,23 @@ ax find backend open # expands to: list --tag backend --status open Relations connect nodes together: -| Type | Direction | Behavior | -|------|-----------|----------| -| `blocks` | issue → issue | Prevents closing until blocker is done | -| `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 | +| Type | Meaning | Behavior | +|------|---------|----------| +| `blocks` | A blocks B — B can't close until A is done | Enforced on status=done | +| `subtask` | A is a subtask of B | | +| `related` | A is related to B | | +| `assignee` | A is assigned to user | Single-value; set via `--assignee` flag | +| `in_namespace` | A belongs to namespace | Single-value; set via `--namespace` flag | ```bash -# Create subtask -ax add "Write tests" --rel subtask:abc12 - -# Block an issue -ax add "Fix login" --rel blocks:def34 +# Block an issue (B can't close until A is done) +ax update A --rel blocks:B # Assign to user -ax update abc12 --rel assignee:alice +ax update abc12 --assignee alice + +# Create subtask +ax update abc12 --rel subtask:parent12 ``` ## Tags and Properties diff --git a/cmd/alias.go b/cmd/alias.go index 8742f8d..f94d805 100644 --- a/cmd/alias.go +++ b/cmd/alias.go @@ -22,11 +22,12 @@ var aliasCmd = &cobra.Command{ return } if len(args) == 1 { - if a, err := cfg.GetAlias(args[0]); err != nil { + a, err := cfg.GetAlias(args[0]) + if err != nil { fmt.Fprintln(os.Stderr, "alias not found:", args[0]) - } else { - fmt.Println(a.Command) + os.Exit(1) } + fmt.Println(a.Command) return } if err := cfg.SetAlias(&service.Alias{Name: args[0], Command: args[1], Description: aliasDesc}); err != nil { @@ -42,9 +43,9 @@ var aliasDelCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { if err := cfg.DeleteAlias(args[0]); err != nil { fmt.Fprintln(os.Stderr, err) - } else { - output.PrintAction(cmd.OutOrStdout(), "Alias deleted", args[0], false) + os.Exit(1) } + output.PrintAction(cmd.OutOrStdout(), "Alias deleted", args[0], false) }, } diff --git a/cmd/del.go b/cmd/del.go index 9bb1b36..660f25a 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -23,8 +23,8 @@ var delCmd = &cobra.Command{ n, err := svc.GetByID(args[0]) if err != nil { - fmt.Fprintf(os.Stderr, "node not found: %s", args[0]) - return + fmt.Fprintf(os.Stderr, "node not found: %s\n", args[0]) + os.Exit(1) } if !dForce { diff --git a/cmd/root.go b/cmd/root.go index 8095273..a8f1055 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,22 +44,38 @@ func registerAliasCommands() { acmd = strings.ReplaceAll(acmd, "$me", cfg.GetUser()) parts := strings.Fields(acmd) var expanded []string + usedArgs := make([]bool, len(args)) for _, part := range parts { if part == "$@" { expanded = append(expanded, args...) + for i := range usedArgs { + usedArgs[i] = true + } continue } hasCatchAll := strings.Contains(part, "$@") replaced := part if hasCatchAll { replaced = strings.ReplaceAll(replaced, "$@", strings.Join(args, " ")) + for i := range usedArgs { + usedArgs[i] = true + } } for i := len(args) - 1; i >= 0; i-- { placeholder := fmt.Sprintf("$%d", i+1) - replaced = strings.ReplaceAll(replaced, placeholder, args[i]) + if strings.Contains(replaced, placeholder) { + replaced = strings.ReplaceAll(replaced, placeholder, args[i]) + usedArgs[i] = true + } } expanded = append(expanded, replaced) } + // Forward any unconsumed args (e.g. --json flag). + for i, arg := range args { + if !usedArgs[i] { + expanded = append(expanded, arg) + } + } rootCmd.SetArgs(expanded) if err := rootCmd.Execute(); err != nil { os.Exit(1) diff --git a/cmd/show.go b/cmd/show.go index 8d8c767..a9b80da 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -18,11 +18,12 @@ var showCmd = &cobra.Command{ return } - if n, err := svc.GetByID(args[0]); err == nil { - output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) - } else { + n, err := svc.GetByID(args[0]) + if err != nil { fmt.Fprintln(os.Stderr, "node not found:", args[0]) + os.Exit(1) } + output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) }, } diff --git a/e2e_test.go b/e2e_test.go index 71dafb1..5a52efb 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -5,194 +5,793 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" ) +// ── JSON response types ─────────────────────────────────────────────────────── + type NodeResponse struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content"` + DueDate string `json:"due_date"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` Tags []string `json:"tags"` Relations map[string][]string `json:"relations"` } -func runAx(t *testing.T, dir string, args ...string) (string, error) { +func (n NodeResponse) HasTag(tag string) bool { return slices.Contains(n.Tags, tag) } +func (n NodeResponse) Property(key string) string { + prefix := "_" + key + "::" + for _, t := range n.Tags { + if strings.HasPrefix(t, prefix) { + return strings.TrimPrefix(t, prefix) + } + } + return "" +} +func (n NodeResponse) HasRelation(relType, targetID string) bool { + return slices.Contains(n.Relations[relType], targetID) +} + +// ── Test environment ────────────────────────────────────────────────────────── + +type testEnv struct { + t *testing.T + dir string +} + +func (e *testEnv) ax(args ...string) (string, error) { cmd := exec.Command("./ax", args...) - cmd.Dir = dir + cmd.Dir = e.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) +func (e *testEnv) mustAx(args ...string) string { + e.t.Helper() + out, err := e.ax(args...) if err != nil { - t.Fatalf("failed to parse JSON output: %v\nOutput: %s", err, out) + e.t.Fatalf("ax %v failed: %v\n%s", args, err, out) } - return node + return out } +func (e *testEnv) parseNode(out string) NodeResponse { + e.t.Helper() + var n NodeResponse + if err := json.Unmarshal([]byte(out), &n); err != nil { + e.t.Fatalf("failed to parse node JSON: %v\noutput: %s", err, out) + } + return n +} + +func (e *testEnv) parseNodes(out string) []NodeResponse { + e.t.Helper() + // service returns JSON null for empty results + if strings.TrimSpace(out) == "null" { + return nil + } + var nodes []NodeResponse + if err := json.Unmarshal([]byte(out), &nodes); err != nil { + e.t.Fatalf("failed to parse node list JSON: %v\noutput: %s", err, out) + } + return nodes +} + +func (e *testEnv) findInList(nodes []NodeResponse, id string) (NodeResponse, bool) { + for _, n := range nodes { + if n.ID == id { + return n, true + } + } + return NodeResponse{}, false +} + +// ── Test setup ──────────────────────────────────────────────────────────────── + 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) + // Build binary once for all tests. + if err := exec.Command("go", "build", "-o", "ax", ".").Run(); err != nil { + t.Fatalf("build failed: %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 { + if err := exec.Command("cp", "./ax", filepath.Join(tmpDir, "ax")).Run(); 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) - } + t.Setenv("AX_USER", "testuser") - // Set test user - os.Setenv("AX_USER", "testuser") - defer os.Unsetenv("AX_USER") + // ── Init ───────────────────────────────────────────────────────────────── - // 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) + t.Run("Init", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + env.mustAx("init") - 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 _, err := os.Stat(filepath.Join(tmpDir, ".ax.db")); err != nil { + t.Fatal(".ax.db not created") } - } - 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 + t.Run("Init_AlreadyExists_Fails", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + _, err := env.ax("init") + if err == nil { + t.Fatal("expected error re-initialising existing DB, got none") } - } - 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) - } + // ── Add ────────────────────────────────────────────────────────────────── - // 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) - } + // Shared node IDs used across the remaining subtests. + var issueID, issue2ID, noteID string - foundNamespace := false - for _, n := range nodes { - if n.ID == node1.ID { - foundNamespace = true + t.Run("Add_IssueDefaults", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("add", "My first issue", "--json") + n := env.parseNode(out) + issueID = n.ID + + if n.Title != "My first issue" { + t.Errorf("title: want %q, got %q", "My first issue", n.Title) } - } - if !foundNamespace { - t.Errorf("Expected node %s in namespace 'testproj', but wasn't found in list output: %v", node1.ID, nodes) - } + if n.Property("type") != "issue" { + t.Errorf("type: want issue, got %q", n.Property("type")) + } + if n.Property("status") != "open" { + t.Errorf("status: want open, got %q", n.Property("status")) + } + if n.Property("prio") != "" { + t.Errorf("prio: want empty, got %q", n.Property("prio")) + } + if len(n.Relations["created"]) == 0 { + t.Error("expected created relation to be set") + } + if len(n.Relations["in_namespace"]) == 0 { + t.Error("expected in_namespace relation to be set (defaults to current user)") + } + if n.CreatedAt == "" || n.UpdatedAt == "" { + t.Error("expected timestamps to be set") + } + }) - // 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) - } + t.Run("Add_WithAllFlags", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("add", "Full issue", + "--prio", "high", + "--status", "open", + "--namespace", "myproject", + "--assignee", "alice", + "--tag", "backend", + "--tag", "urgent", + "--due", "2099-12-31", + "--content", "some body", + "--json", + ) + n := env.parseNode(out) + issue2ID = n.ID - // 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) - } + if n.Property("type") != "issue" { + t.Errorf("type: want issue, got %q", n.Property("type")) + } + if n.Property("prio") != "high" { + t.Errorf("prio: want high, got %q", n.Property("prio")) + } + if n.Property("status") != "open" { + t.Errorf("status: want open, got %q", n.Property("status")) + } + if !n.HasTag("backend") { + t.Error("expected tag 'backend'") + } + if !n.HasTag("urgent") { + t.Error("expected tag 'urgent'") + } + if n.DueDate != "2099-12-31" { + t.Errorf("due_date: want 2099-12-31, got %q", n.DueDate) + } + if n.Content != "some body" { + t.Errorf("content: want %q, got %q", "some body", n.Content) + } + if len(n.Relations["in_namespace"]) == 0 { + t.Error("expected in_namespace relation") + } + if len(n.Relations["assignee"]) == 0 { + t.Error("expected assignee relation") + } + }) - 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") - } + t.Run("Add_NoteType_NoDefaultStatus", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("add", "A note", "--type", "note", "--json") + n := env.parseNode(out) + noteID = n.ID - // Test completed successfully + if n.Property("type") != "note" { + t.Errorf("type: want note, got %q", n.Property("type")) + } + // Notes should NOT get a default status + if n.Property("status") != "" { + t.Errorf("note should have no default status, got %q", n.Property("status")) + } + }) + + t.Run("Add_CustomRelation", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" || issue2ID == "" { + t.Skip("prerequisite nodes missing") + } + // issue2 blocks issueID + out := env.mustAx("update", issue2ID, "--rel", "blocks:"+issueID, "--json") + n := env.parseNode(out) + if !n.HasRelation("blocks", issueID) { + t.Errorf("expected blocks relation to %s, got relations: %v", issueID, n.Relations) + } + }) + + // ── Show ───────────────────────────────────────────────────────────────── + + t.Run("Show_Fields", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issue2ID == "" { + t.Skip("prerequisite node missing") + } + out := env.mustAx("show", issue2ID, "--json") + n := env.parseNode(out) + + if n.ID != issue2ID { + t.Errorf("id: want %q, got %q", issue2ID, n.ID) + } + if n.Title != "Full issue" { + t.Errorf("title: want %q, got %q", "Full issue", n.Title) + } + if n.DueDate != "2099-12-31" { + t.Errorf("due_date: want 2099-12-31, got %q", n.DueDate) + } + if n.Content != "some body" { + t.Errorf("content: want %q, got %q", "some body", n.Content) + } + }) + + t.Run("Show_NotFound_Fails", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + _, err := env.ax("show", "xxxxx") + if err == nil { + t.Fatal("expected error for unknown ID, got none") + } + }) + + // ── List ───────────────────────────────────────────────────────────────── + + t.Run("List_All", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("list", "--json") + nodes := env.parseNodes(out) + if len(nodes) < 3 { + t.Errorf("expected at least 3 nodes, got %d", len(nodes)) + } + }) + + t.Run("List_ByStatus", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("list", "--status", "open", "--json") + nodes := env.parseNodes(out) + for _, n := range nodes { + if n.Property("status") != "open" { + t.Errorf("node %s has status %q, expected only open nodes", n.ID, n.Property("status")) + } + } + if _, ok := env.findInList(nodes, issueID); !ok { + t.Errorf("expected issueID %s in open list", issueID) + } + }) + + t.Run("List_ByPrio", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("list", "--prio", "high", "--json") + nodes := env.parseNodes(out) + for _, n := range nodes { + if n.Property("prio") != "high" { + t.Errorf("node %s has prio %q, expected only high-priority nodes", n.ID, n.Property("prio")) + } + } + if _, ok := env.findInList(nodes, issue2ID); !ok { + t.Errorf("expected issue2ID %s in high-prio list", issue2ID) + } + if _, ok := env.findInList(nodes, issueID); ok { + t.Errorf("issueID %s has no prio, should not appear in high-prio list", issueID) + } + }) + + t.Run("List_ByType", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("list", "--type", "note", "--json") + nodes := env.parseNodes(out) + for _, n := range nodes { + if n.Property("type") != "note" { + t.Errorf("node %s has type %q, expected only notes", n.ID, n.Property("type")) + } + } + if _, ok := env.findInList(nodes, noteID); !ok { + t.Errorf("expected noteID %s in note list", noteID) + } + if _, ok := env.findInList(nodes, issueID); ok { + t.Errorf("issueID %s should not appear in note list", issueID) + } + }) + + t.Run("List_ByNamespace", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("list", "--namespace", "myproject", "--json") + nodes := env.parseNodes(out) + if _, ok := env.findInList(nodes, issue2ID); !ok { + t.Errorf("issue2ID %s should be in namespace 'myproject'", issue2ID) + } + if _, ok := env.findInList(nodes, issueID); ok { + t.Errorf("issueID %s should NOT be in namespace 'myproject'", issueID) + } + }) + + t.Run("List_ByAssignee", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("list", "--assignee", "alice", "--json") + nodes := env.parseNodes(out) + if _, ok := env.findInList(nodes, issue2ID); !ok { + t.Errorf("issue2ID %s should appear in alice's assignments", issue2ID) + } + if _, ok := env.findInList(nodes, issueID); ok { + t.Errorf("issueID %s is not assigned to alice, should not appear", issueID) + } + }) + + t.Run("List_ByTag", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("list", "--tag", "backend", "--json") + nodes := env.parseNodes(out) + for _, n := range nodes { + if !n.HasTag("backend") { + t.Errorf("node %s lacks 'backend' tag, should not be in result", n.ID) + } + } + if _, ok := env.findInList(nodes, issue2ID); !ok { + t.Errorf("issue2ID %s (tagged backend) should be in result", issue2ID) + } + }) + + t.Run("List_MultipleTagFilters", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + // Both tags must match; a node with only one should be excluded. + out := env.mustAx("list", "--tag", "backend", "--tag", "urgent", "--json") + nodes := env.parseNodes(out) + for _, n := range nodes { + if !n.HasTag("backend") || !n.HasTag("urgent") { + t.Errorf("node %s does not have both tags", n.ID) + } + } + if _, ok := env.findInList(nodes, issue2ID); !ok { + t.Errorf("issue2ID %s should match both tags", issue2ID) + } + }) + + t.Run("List_NoMatch_ReturnsEmpty", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("list", "--tag", "nonexistent-tag-xyz", "--json") + nodes := env.parseNodes(out) + if len(nodes) != 0 { + t.Errorf("expected empty result, got %d nodes", len(nodes)) + } + }) + + // ── Update ─────────────────────────────────────────────────────────────── + + t.Run("Update_Title", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" { + t.Skip("prerequisite node missing") + } + out := env.mustAx("update", issueID, "--title", "Updated title", "--json") + n := env.parseNode(out) + if n.Title != "Updated title" { + t.Errorf("title: want %q, got %q", "Updated title", n.Title) + } + }) + + t.Run("Update_Content", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" { + t.Skip("prerequisite node missing") + } + out := env.mustAx("update", issueID, "--content", "New content body", "--json") + n := env.parseNode(out) + if n.Content != "New content body" { + t.Errorf("content: want %q, got %q", "New content body", n.Content) + } + }) + + t.Run("Update_Priority", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" { + t.Skip("prerequisite node missing") + } + out := env.mustAx("update", issueID, "--prio", "medium", "--json") + n := env.parseNode(out) + if n.Property("prio") != "medium" { + t.Errorf("prio: want medium, got %q", n.Property("prio")) + } + // Only one _prio tag should exist. + var prioTags []string + for _, tag := range n.Tags { + if strings.HasPrefix(tag, "_prio::") { + prioTags = append(prioTags, tag) + } + } + if len(prioTags) != 1 { + t.Errorf("expected exactly 1 _prio tag, got %v", prioTags) + } + }) + + t.Run("Update_DueDate", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" { + t.Skip("prerequisite node missing") + } + out := env.mustAx("update", issueID, "--due", "2099-01-01", "--json") + n := env.parseNode(out) + if n.DueDate != "2099-01-01" { + t.Errorf("due_date: want 2099-01-01, got %q", n.DueDate) + } + }) + + t.Run("Update_ClearDueDate", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" { + t.Skip("prerequisite node missing") + } + out := env.mustAx("update", issueID, "--clear-due", "--json") + n := env.parseNode(out) + if n.DueDate != "" { + t.Errorf("due_date: want empty after --clear-due, got %q", n.DueDate) + } + }) + + t.Run("Update_AddAndRemoveTag", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" { + t.Skip("prerequisite node missing") + } + env.mustAx("update", issueID, "--tag", "mytag") + out := env.mustAx("show", issueID, "--json") + if !env.parseNode(out).HasTag("mytag") { + t.Error("expected tag 'mytag' after add") + } + + env.mustAx("update", issueID, "--tag-remove", "mytag") + out = env.mustAx("show", issueID, "--json") + if env.parseNode(out).HasTag("mytag") { + t.Error("expected tag 'mytag' to be gone after remove") + } + }) + + t.Run("Update_Assignee", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" { + t.Skip("prerequisite node missing") + } + out := env.mustAx("update", issueID, "--assignee", "bob", "--json") + n := env.parseNode(out) + if len(n.Relations["assignee"]) == 0 { + t.Error("expected assignee relation after --assignee bob") + } + // Verify bob appears in list filtered by assignee. + listOut := env.mustAx("list", "--assignee", "bob", "--json") + listed := env.parseNodes(listOut) + if _, ok := env.findInList(listed, issueID); !ok { + t.Errorf("issueID %s not found in --assignee bob list", issueID) + } + }) + + t.Run("Update_Assignee_Replaces_Previous", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" { + t.Skip("prerequisite node missing") + } + out := env.mustAx("update", issueID, "--assignee", "carol", "--json") + n := env.parseNode(out) + if len(n.Relations["assignee"]) != 1 { + t.Errorf("expected exactly 1 assignee, got %v", n.Relations["assignee"]) + } + // bob should no longer be assignee + listOut := env.mustAx("list", "--assignee", "bob", "--json") + listed := env.parseNodes(listOut) + if _, ok := env.findInList(listed, issueID); ok { + t.Errorf("issueID %s still appears under bob after reassigning to carol", issueID) + } + }) + + t.Run("Update_AddAndRemoveRelation", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" || noteID == "" { + t.Skip("prerequisite nodes missing") + } + // Link issueID as related to noteID. + env.mustAx("update", issueID, "--rel", "related:"+noteID) + out := env.mustAx("show", issueID, "--json") + n := env.parseNode(out) + if !n.HasRelation("related", noteID) { + t.Errorf("expected related relation to %s", noteID) + } + + // Remove it. + env.mustAx("update", issueID, "--rel-remove", "related:"+noteID) + out = env.mustAx("show", issueID, "--json") + n = env.parseNode(out) + if n.HasRelation("related", noteID) { + t.Errorf("related relation to %s should be gone after rel-remove", noteID) + } + }) + + t.Run("Update_Namespace", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" { + t.Skip("prerequisite node missing") + } + env.mustAx("update", issueID, "--namespace", "otherproject") + out := env.mustAx("list", "--namespace", "otherproject", "--json") + nodes := env.parseNodes(out) + if _, ok := env.findInList(nodes, issueID); !ok { + t.Errorf("issueID %s should be in namespace 'otherproject' after update", issueID) + } + // Should no longer be in old namespace (testuser's). + prevOut := env.mustAx("list", "--namespace", "testuser", "--json") + prev := env.parseNodes(prevOut) + if _, ok := env.findInList(prev, issueID); ok { + t.Errorf("issueID %s should NOT be in old namespace after namespace update", issueID) + } + }) + + // ── Blocking ───────────────────────────────────────────────────────────── + + t.Run("Blocking_CannotCloseBlockedIssue", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" || issue2ID == "" { + t.Skip("prerequisite nodes missing") + } + // issue2ID blocks issueID (set in Add_CustomRelation). + // issue2ID is still open, so closing issueID must fail. + out, err := env.ax("update", issueID, "--status", "done") + if err == nil { + t.Fatal("expected error closing blocked issue, got none") + } + if !strings.Contains(out, "cannot close") { + t.Errorf("expected 'cannot close' in output, got: %s", out) + } + }) + + t.Run("Blocking_CloseBlockerThenClose", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" || issue2ID == "" { + t.Skip("prerequisite nodes missing") + } + // Close the blocker first. + env.mustAx("update", issue2ID, "--status", "done") + // Now closing issueID must succeed. + out := env.mustAx("update", issueID, "--status", "done", "--json") + n := env.parseNode(out) + if n.Property("status") != "done" { + t.Errorf("status: want done, got %q", n.Property("status")) + } + }) + + t.Run("Blocking_ReopenAndVerify", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if issueID == "" { + t.Skip("prerequisite node missing") + } + out := env.mustAx("update", issueID, "--status", "open", "--json") + n := env.parseNode(out) + if n.Property("status") != "open" { + t.Errorf("status: want open after reopen, got %q", n.Property("status")) + } + }) + + // ── Mentions ───────────────────────────────────────────────────────────── + + var mentionNodeID string + + t.Run("Mentions_AutoCreateUser", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("add", "Review this @diana", "--json") + n := env.parseNode(out) + mentionNodeID = n.ID + + if len(n.Relations["mentions"]) == 0 { + t.Fatal("expected mentions relation to be created for @diana") + } + // Verify the mention target is a user node. + dianaID := n.Relations["mentions"][0] + userOut := env.mustAx("show", dianaID, "--json") + user := env.parseNode(userOut) + if user.Title != "diana" { + t.Errorf("mention target title: want diana, got %q", user.Title) + } + if user.Property("type") != "user" { + t.Errorf("mention target type: want user, got %q", user.Property("type")) + } + }) + + t.Run("Mentions_InboxFilter", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if mentionNodeID == "" { + t.Skip("prerequisite node missing") + } + out := env.mustAx("list", "--mention", "diana", "--json") + nodes := env.parseNodes(out) + if _, ok := env.findInList(nodes, mentionNodeID); !ok { + t.Errorf("mentionNodeID %s should appear in diana's inbox", mentionNodeID) + } + }) + + t.Run("Mentions_SyncOnTitleUpdate", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + if mentionNodeID == "" { + t.Skip("prerequisite node missing") + } + // Replace @diana with @eve. + out := env.mustAx("update", mentionNodeID, "--title", "Review this @eve", "--json") + n := env.parseNode(out) + + if len(n.Relations["mentions"]) != 1 { + t.Errorf("expected exactly 1 mention after update, got %v", n.Relations["mentions"]) + } + // The mention target should now be eve, not diana. + eveID := n.Relations["mentions"][0] + userOut := env.mustAx("show", eveID, "--json") + if env.parseNode(userOut).Title != "eve" { + t.Errorf("mention target after update should be eve") + } + // Diana's inbox should be empty for this node. + dianaList := env.mustAx("list", "--mention", "diana", "--json") + dianaNodes := env.parseNodes(dianaList) + if _, ok := env.findInList(dianaNodes, mentionNodeID); ok { + t.Errorf("mentionNodeID %s should no longer be in diana's inbox", mentionNodeID) + } + // Eve's inbox should contain the node. + eveList := env.mustAx("list", "--mention", "eve", "--json") + eveNodes := env.parseNodes(eveList) + if _, ok := env.findInList(eveNodes, mentionNodeID); !ok { + t.Errorf("mentionNodeID %s should now be in eve's inbox", mentionNodeID) + } + }) + + t.Run("Mentions_ContentMention", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("add", "No mention in title", + "--content", "But @frank is mentioned here", "--json") + n := env.parseNode(out) + if len(n.Relations["mentions"]) == 0 { + t.Fatal("expected mention from content field") + } + frankID := n.Relations["mentions"][0] + frankOut := env.mustAx("show", frankID, "--json") + if env.parseNode(frankOut).Title != "frank" { + t.Error("content mention should create user frank") + } + }) + + // ── Users ───────────────────────────────────────────────────────────────── + + t.Run("Users_ListedAsNodes", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("list", "--type", "user", "--json") + users := env.parseNodes(out) + titles := make([]string, len(users)) + for i, u := range users { + titles[i] = u.Title + } + for _, name := range []string{"testuser", "alice", "diana", "eve", "frank"} { + if !slices.Contains(titles, name) { + t.Errorf("expected user %q to exist, got users: %v", name, titles) + } + } + }) + + // ── Delete ─────────────────────────────────────────────────────────────── + + t.Run("Delete_Force", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + // Create a throwaway node. + out := env.mustAx("add", "Delete me", "--json") + id := env.parseNode(out).ID + + env.mustAx("del", id, "--force") + + // Verify it's gone. + _, err := env.ax("show", id) + if err == nil { + t.Errorf("show after delete should fail, node %s still exists", id) + } + }) + + t.Run("Delete_NotFound_Fails", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + _, err := env.ax("del", "xxxxx", "--force") + if err == nil { + t.Fatal("expected error deleting non-existent node, got none") + } + }) + + // ── Aliases ────────────────────────────────────────────────────────────── + + t.Run("Alias_DefaultsPresent", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + out := env.mustAx("alias", "--json") + var aliases []map[string]string + if err := json.Unmarshal([]byte(out), &aliases); err != nil { + t.Fatalf("failed to parse alias JSON: %v\n%s", err, out) + } + names := make([]string, len(aliases)) + for i, a := range aliases { + names[i] = a["name"] + } + for _, want := range []string{"mine", "due", "inbox"} { + if !slices.Contains(names, want) { + t.Errorf("default alias %q not found in: %v", want, names) + } + } + }) + + t.Run("Alias_Create_Show_Delete", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + env.mustAx("alias", "myopen", "list --status open", "--desc", "My open issues") + + // Show the alias. + showOut, err := env.ax("alias", "myopen") + if err != nil { + t.Fatalf("alias show failed: %v\n%s", err, showOut) + } + if !strings.Contains(showOut, "list --status open") { + t.Errorf("alias command not found in show output: %s", showOut) + } + + // Delete it. + env.mustAx("alias", "del", "myopen") + + // Should be gone. + _, err = env.ax("alias", "myopen") + if err == nil { + t.Fatal("expected error after alias deletion, got none") + } + }) + + t.Run("Alias_CannotDeleteDefault", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + _, err := env.ax("alias", "del", "inbox") + if err == nil { + t.Fatal("expected error deleting default alias, got none") + } + }) + + t.Run("Alias_Execute", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + // The built-in 'due' alias lists open issues; we just verify it runs without error. + out := env.mustAx("due", "--json") + // Result must be parseable (array or null). + env.parseNodes(out) + }) + + t.Run("Alias_Execute_WithMeExpansion", func(t *testing.T) { + env := &testEnv{t: t, dir: tmpDir} + // 'mine' expands $me to AX_USER=testuser. + out := env.mustAx("mine", "--json") + env.parseNodes(out) // must parse without error + }) } diff --git a/service/node_service_impl.go b/service/node_service_impl.go index 785c30b..5518407 100644 --- a/service/node_service_impl.go +++ b/service/node_service_impl.go @@ -324,18 +324,15 @@ func (s *nodeServiceImpl) ListUsers() ([]*models.Node, error) { // --- Internal helpers --- func (s *nodeServiceImpl) checkBlockers(id string) error { - node, err := s.store.GetNode(id) + // Find all nodes that declare a blocks → id relation (i.e., open blockers). + blockers, err := s.store.FindNodes(nil, []*models.Rel{{Type: models.RelBlocks, Target: id}}) if err != nil { return err } var blocking []string - for _, bID := range node.Relations()[string(models.RelBlocks)] { - blocker, err := s.store.GetNode(bID) - if err != nil { - return err - } - if blocker.GetProperty("status") == "open" { - blocking = append(blocking, bID) + for _, b := range blockers { + if b.GetProperty("status") == "open" { + blocking = append(blocking, b.ID) } } if len(blocking) > 0 {