test: add e2e test suite and fix namespace/mention/assignee flags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 22:36:49 +02:00
parent 8d831d131b
commit 4404518f50
7 changed files with 794 additions and 177 deletions

View File

@@ -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 <id>`
@@ -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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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 {