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

This commit is contained in:
2026-03-29 23:56:43 +02:00
parent dadd3d9e13
commit d569a4dea9
6 changed files with 236 additions and 16 deletions

View File

@@ -157,6 +157,7 @@ Relations connect nodes together:
| `subtask` | issue → issue | Shows as tree under parent | | `subtask` | issue → issue | Shows as tree under parent |
| `related` | any ↔ any | Shown in related section | | `related` | any ↔ any | Shown in related section |
| `assignee` | issue → user | Adds to user's inbox | | `assignee` | issue → user | Adds to user's inbox |
| `in_namespace`| any → namespace | Groups nodes by project or team |
```bash ```bash
# Create subtask # Create subtask
@@ -189,7 +190,6 @@ ax add "Task" --type issue --status open --prio high
| `_type` | `issue`, `note`, `user`, `namespace` | Yes (default: `issue`) | | `_type` | `issue`, `note`, `user`, `namespace` | Yes (default: `issue`) |
| `_status` | `open`, `done` | No | | `_status` | `open`, `done` | No |
| `_prio` | `high`, `medium`, `low` | No | | `_prio` | `high`, `medium`, `low` | No |
| `_namespace` | any string | Yes (default: current user) |
## Mentions and Inbox ## Mentions and Inbox

View File

@@ -24,8 +24,8 @@ var addCmd = &cobra.Command{
} }
// default relations // default relations
if !slices.ContainsFunc(cRels, func(e string) bool { return strings.HasPrefix(e, "_namespace::") }) { if !slices.ContainsFunc(cRels, func(e string) bool { return strings.HasPrefix(e, "in_namespace:") }) {
cRels = append(cRels, "_namespace::"+cfg.GetUser()) cRels = append(cRels, "in_namespace:"+cfg.GetUser())
} }
// parse relations // parse relations

View File

@@ -36,6 +36,8 @@ func addPropertyFlags(cmd *cobra.Command) {
cmd.Flags().String("status", "", "node status") cmd.Flags().String("status", "", "node status")
cmd.Flags().String("prio", "", "node priority") cmd.Flags().String("prio", "", "node priority")
cmd.Flags().String("namespace", "", "node namespace") cmd.Flags().String("namespace", "", "node namespace")
cmd.Flags().String("assignee", "", "node assignee")
cmd.Flags().String("mention", "", "node mention")
} }
func registerAliasCommands() { func registerAliasCommands() {
@@ -78,24 +80,44 @@ func registerAliasCommands() {
} }
func transformArgs(args []string) []string { func transformArgs(args []string) []string {
aliases := map[string]string{ tagAliases := map[string]string{
"--status": "_status", "--status": "_status",
"--prio": "_prio", "--prio": "_prio",
"--type": "_type", "--type": "_type",
"--namespace": "_namespace", }
relAliases := map[string]string{
"--namespace": "in_namespace",
"--assignee": "assignee",
"--mention": "mentions",
} }
result := []string{} result := []string{}
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
if idx := strings.Index(args[i], "="); idx != -1 { if idx := strings.Index(args[i], "="); idx != -1 {
if prop, ok := aliases[args[i][:idx]]; ok { flag := args[i][:idx]
result = append(result, "--tag", prop+"::"+args[i][idx+1:]) 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 continue
} }
} }
if prop, ok := aliases[args[i]]; ok && i+1 < len(args) {
result, i = append(result, "--tag", prop+"::"+args[i+1]), i+1 flag := args[i]
continue 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]) result = append(result, args[i])
} }

View File

@@ -55,11 +55,11 @@ var updateCmd = &cobra.Command{
ok, blockers, err := svc.CanClose(args[0]) ok, blockers, err := svc.CanClose(args[0])
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "failed to check blockers:", err) fmt.Fprintln(os.Stderr, "failed to check blockers:", err)
return os.Exit(1)
} }
if !ok { if !ok {
fmt.Fprintf(os.Stderr, "cannot close: blocked by %v\n", blockers) fmt.Fprintf(os.Stderr, "cannot close: blocked by %v\n", blockers)
return os.Exit(1)
} }
} }

198
e2e_test.go Normal file
View File

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

View File

@@ -134,7 +134,7 @@ func (n *Node) AddRelation(relType RelType, target string) {
if n.relations == nil { if n.relations == nil {
n.relations = make(map[string][]string) n.relations = make(map[string][]string)
} }
if relType == RelAssignee || relType == RelCreated { if relType == RelAssignee || relType == RelCreated || relType == RelInNamespace {
n.relations[string(relType)] = []string{target} n.relations[string(relType)] = []string{target}
return return
} }