diff --git a/src/cmd/init.go b/src/cmd/init.go index ced7a1a..dd5778d 100644 --- a/src/cmd/init.go +++ b/src/cmd/init.go @@ -12,14 +12,17 @@ import ( var initCmd = &cobra.Command{ Use: "init [path]", Short: "Initialize a new database", Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - dataRoot, err := store.FindDataRoot(".local", "share") + var dbPath string if len(args) > 0 { - dataRoot = args[0] - } else if err != nil { - fmt.Fprintln(os.Stderr, "failed to find data dir:", err) - os.Exit(1) + dbPath = filepath.Join(args[0], "ax.db") + } else { + cwd, err := filepath.Abs(".") + if err != nil { + fmt.Fprintln(os.Stderr, "failed to get working directory:", err) + os.Exit(1) + } + dbPath = filepath.Join(cwd, ".ax", "ax.db") } - dbPath := filepath.Join(dataRoot, "ax.db") if _, err := os.Stat(dbPath); err == nil { fmt.Fprintln(os.Stderr, "database already exists:", dbPath) os.Exit(1) diff --git a/src/cmd/output.go b/src/cmd/output.go index ed68aab..32ddcdc 100644 --- a/src/cmd/output.go +++ b/src/cmd/output.go @@ -116,8 +116,8 @@ func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut boo fmt.Fprintln(w, cDim.Sprint(" ───────────────────────────────")) fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false)) fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false)) - if n.DueDate != "" { - fmt.Fprintf(w, " Due: %s %s\n", iconCalendar, n.DueDate) + if n.DueDate != nil { + fmt.Fprintf(w, " Due: %s %s\n", iconCalendar, n.DueDate.Format("2006-01-02")) } fmt.Fprintf(w, " Created: %s\n", cDim.Sprint(n.CreatedAt)) fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt)) diff --git a/src/e2e_aliases_test.go b/src/e2e_aliases_test.go new file mode 100644 index 0000000..0c1d6e0 --- /dev/null +++ b/src/e2e_aliases_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "encoding/json" + "slices" + "strings" + "testing" +) + +func TestAliases(t *testing.T) { + env := newTestEnv(t, "testuser") + + t.Run("DefaultsPresent", func(t *testing.T) { + 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("Create_Show_Delete", func(t *testing.T) { + env.mustAx("alias", "myopen", "list --status open", "--desc", "My open issues") + + 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) + } + + env.mustAx("alias", "del", "myopen") + + _, err = env.ax("alias", "myopen") + if err == nil { + t.Fatal("expected error after alias deletion, got none") + } + }) + + t.Run("CannotDeleteDefault", func(t *testing.T) { + _, err := env.ax("alias", "del", "inbox") + if err == nil { + t.Fatal("expected error deleting default alias, got none") + } + }) + + t.Run("Execute_Due", func(t *testing.T) { + // The built-in 'due' alias lists open issues. + out := env.mustAx("due", "--json") + env.parseNodes(out) + }) + + t.Run("Execute_Mine_WithMeExpansion", func(t *testing.T) { + // 'mine' expands $me to AX_USER=testuser. + out := env.mustAx("mine", "--json") + env.parseNodes(out) + }) +} diff --git a/src/e2e_crud_test.go b/src/e2e_crud_test.go new file mode 100644 index 0000000..adbe6f7 --- /dev/null +++ b/src/e2e_crud_test.go @@ -0,0 +1,549 @@ +package main + +import ( + "slices" + "strings" + "testing" +) + +func TestCRUD(t *testing.T) { + env := newTestEnv(t, "testuser") + + var issueID, issue2ID, noteID string + + // ── Add ────────────────────────────────────────────────────────────────── + + t.Run("Add_IssueDefaults", func(t *testing.T) { + 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 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") + } + if n.CreatedAt == "" || n.UpdatedAt == "" { + t.Error("expected timestamps to be set") + } + }) + + t.Run("Add_WithAllFlags", func(t *testing.T) { + out := env.mustAx("add", "Full issue", + "--prio", "high", + "--status", "open", + "--namespace", "myproject", + "--assignee", "alice", + "--tag", "backend", + "--tag", "urgent", + "--content", "some body", + "--json", + ) + n := env.parseNode(out) + issue2ID = n.ID + + 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.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") + } + }) + + t.Run("Add_NoteType_NoDefaultStatus", func(t *testing.T) { + out := env.mustAx("add", "A note", "--type", "note", "--json") + n := env.parseNode(out) + noteID = n.ID + + if n.Property("type") != "note" { + t.Errorf("type: want note, got %q", n.Property("type")) + } + 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) { + if issueID == "" || issue2ID == "" { + t.Skip("prerequisite nodes missing") + } + 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) { + 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.Content != "some body" { + t.Errorf("content: want %q, got %q", "some body", n.Content) + } + }) + + t.Run("Show_NotFound_Fails", func(t *testing.T) { + _, 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) { + 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) { + 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", n.ID, n.Property("status")) + } + } + if issueID != "" { + if _, ok := env.findInList(nodes, issueID); !ok { + t.Errorf("expected issueID %s in open list", issueID) + } + } + }) + + t.Run("List_ByPrio", func(t *testing.T) { + if issue2ID == "" { + t.Skip("prerequisite node missing") + } + 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", n.ID, n.Property("prio")) + } + } + if _, ok := env.findInList(nodes, issue2ID); !ok { + t.Errorf("expected issue2ID %s in high-prio list", issue2ID) + } + if issueID != "" { + 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) { + if noteID == "" { + t.Skip("prerequisite node missing") + } + 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 issueID != "" { + 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) { + if issue2ID == "" || issueID == "" { + t.Skip("prerequisite nodes missing") + } + 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) { + if issue2ID == "" || issueID == "" { + t.Skip("prerequisite nodes missing") + } + 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", issueID) + } + }) + + t.Run("List_ByTag", func(t *testing.T) { + if issue2ID == "" { + t.Skip("prerequisite node missing") + } + 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", 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) { + if issue2ID == "" { + t.Skip("prerequisite node missing") + } + 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) { + 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) { + 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) { + 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) { + 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")) + } + 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_AddAndRemoveTag", func(t *testing.T) { + 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) { + 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") + } + 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) { + 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"]) + } + 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) { + if issueID == "" || noteID == "" { + t.Skip("prerequisite nodes missing") + } + 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) + } + + 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) { + 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) + } + 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) { + if issueID == "" || issue2ID == "" { + t.Skip("prerequisite nodes missing") + } + // issue2ID blocks issueID (set in Add_CustomRelation); issue2ID is still open. + 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) { + if issueID == "" || issue2ID == "" { + t.Skip("prerequisite nodes missing") + } + env.mustAx("update", issue2ID, "--status", "done") + 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) { + 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) { + 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") + } + 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) { + 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) { + if mentionNodeID == "" { + t.Skip("prerequisite node missing") + } + 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"]) + } + 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") + } + 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) + } + 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) { + 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) { + 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) { + out := env.mustAx("add", "Delete me", "--json") + id := env.parseNode(out).ID + + env.mustAx("del", id, "--force") + + _, 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) { + _, err := env.ax("del", "xxxxx", "--force") + if err == nil { + t.Fatal("expected error deleting non-existent node, got none") + } + }) +} diff --git a/src/e2e_duedate_test.go b/src/e2e_duedate_test.go new file mode 100644 index 0000000..cd89875 --- /dev/null +++ b/src/e2e_duedate_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "testing" +) + +func TestDueDate(t *testing.T) { + env := newTestEnv(t, "testuser") + + t.Run("SetOnCreate", func(t *testing.T) { + out := env.mustAx("add", "Issue with due date", "--due", "2099-12-31", "--json") + n := env.parseNode(out) + if n.DueDate != "2099-12-31" { + t.Errorf("due_date: want 2099-12-31, got %q", n.DueDate) + } + }) + + t.Run("PersistsOnShow", func(t *testing.T) { + out := env.mustAx("add", "Issue for show test", "--due", "2050-06-15", "--json") + id := env.parseNode(out).ID + + showOut := env.mustAx("show", id, "--json") + n := env.parseNode(showOut) + if n.DueDate != "2050-06-15" { + t.Errorf("due_date after show: want 2050-06-15, got %q", n.DueDate) + } + }) + + t.Run("Update", func(t *testing.T) { + out := env.mustAx("add", "Issue to update due", "--due", "2099-01-01", "--json") + id := env.parseNode(out).ID + + updOut := env.mustAx("update", id, "--due", "2099-06-30", "--json") + n := env.parseNode(updOut) + if n.DueDate != "2099-06-30" { + t.Errorf("due_date after update: want 2099-06-30, got %q", n.DueDate) + } + }) + + t.Run("ClearWithFlag", func(t *testing.T) { + out := env.mustAx("add", "Issue to clear due", "--due", "2099-01-01", "--json") + id := env.parseNode(out).ID + + updOut := env.mustAx("update", id, "--clear-due", "--json") + n := env.parseNode(updOut) + if n.DueDate != "" { + t.Errorf("due_date after --clear-due: want empty, got %q", n.DueDate) + } + }) + + t.Run("NotSetByDefault", func(t *testing.T) { + out := env.mustAx("add", "Issue without due date", "--json") + n := env.parseNode(out) + if n.DueDate != "" { + t.Errorf("due_date should be empty when not set, got %q", n.DueDate) + } + }) + + t.Run("PersistsAcrossOtherUpdates", func(t *testing.T) { + out := env.mustAx("add", "Issue with due and title", "--due", "2077-07-07", "--json") + id := env.parseNode(out).ID + + // Updating title should not change due date. + updOut := env.mustAx("update", id, "--title", "New title", "--json") + n := env.parseNode(updOut) + if n.DueDate != "2077-07-07" { + t.Errorf("due_date should be preserved after title update, got %q", n.DueDate) + } + }) + + t.Run("InListOutput", func(t *testing.T) { + out := env.mustAx("add", "Listed issue with due", "--due", "2088-03-01", "--json") + id := env.parseNode(out).ID + + listOut := env.mustAx("list", "--json") + nodes := env.parseNodes(listOut) + n, ok := env.findInList(nodes, id) + if !ok { + t.Fatalf("node %s not found in list output", id) + } + if n.DueDate != "2088-03-01" { + t.Errorf("due_date in list: want 2088-03-01, got %q", n.DueDate) + } + }) +} diff --git a/src/e2e_init_test.go b/src/e2e_init_test.go new file mode 100644 index 0000000..5749590 --- /dev/null +++ b/src/e2e_init_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestInit(t *testing.T) { + t.Run("CreatesDatabase", func(t *testing.T) { + env := &testEnv{t: t, dir: t.TempDir(), user: "testuser"} + env.mustAx("init") + if _, err := os.Stat(filepath.Join(env.dir, ".ax", "ax.db")); err != nil { + t.Fatal(".ax/ax.db not created") + } + }) + + t.Run("FailsIfAlreadyExists", func(t *testing.T) { + env := newTestEnv(t, "testuser") + _, err := env.ax("init") + if err == nil { + t.Fatal("expected error re-initialising existing DB, got none") + } + }) +} diff --git a/src/e2e_permissions_test.go b/src/e2e_permissions_test.go new file mode 100644 index 0000000..aebea51 --- /dev/null +++ b/src/e2e_permissions_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "testing" +) + +func TestPermissions(t *testing.T) { + alice := newTestEnv(t, "alice") + bob := alice.withUser("bob") + + // Alice creates a node (gets has_ownership automatically). + aliceNode := alice.parseNode(alice.mustAx("add", "Alice's secret", "--json")) + aliceNodeID := aliceNode.ID + + // Resolve alice's user node ID. + var aliceUserID string + for _, u := range alice.parseNodes(alice.mustAx("list", "--type", "user", "--json")) { + if u.Title == "alice" { + aliceUserID = u.ID + } + } + if aliceUserID == "" { + t.Fatal("could not resolve alice's user node ID") + } + + t.Run("NoAccess_CannotShow", func(t *testing.T) { + _, err := bob.ax("show", aliceNodeID) + if err == nil { + t.Error("bob should not be able to show alice's node without access") + } + }) + + t.Run("NoAccess_NotInList", func(t *testing.T) { + bob.mustAx("add", "Bob's scratch", "--json") + + nodes := bob.parseNodes(bob.mustAx("list", "--json")) + if _, ok := bob.findInList(nodes, aliceNodeID); ok { + t.Error("alice's node should not appear in bob's list without access") + } + }) + + t.Run("NoAccess_CannotUpdate", func(t *testing.T) { + _, err := bob.ax("update", aliceNodeID, "--title", "hacked") + if err == nil { + t.Error("bob should not be able to update alice's node without access") + } + }) + + // Resolve bob's user node ID. User nodes are globally readable. + var bobUserID string + for _, u := range alice.parseNodes(alice.mustAx("list", "--type", "user", "--json")) { + if u.Title == "bob" { + bobUserID = u.ID + } + } + if bobUserID == "" { + t.Fatal("could not resolve bob's user node ID") + } + + t.Run("SelfEscalation_Denied", func(t *testing.T) { + _, err := bob.ax("update", bobUserID, "--rel", "can_write:"+aliceNodeID) + if err == nil { + t.Error("bob should not be able to grant himself write access to alice's node") + } + }) + + t.Run("ReadAccess_Grant", func(t *testing.T) { + alice.mustAx("update", bobUserID, "--rel", "can_read:"+aliceNodeID) + + if _, err := bob.ax("show", aliceNodeID); err != nil { + t.Error("bob should be able to show alice's node after read access granted") + } + nodes := bob.parseNodes(bob.mustAx("list", "--json")) + if _, ok := bob.findInList(nodes, aliceNodeID); !ok { + t.Error("alice's node should appear in bob's list after read access granted") + } + }) + + t.Run("ReadAccess_CannotUpdate", func(t *testing.T) { + _, err := bob.ax("update", aliceNodeID, "--title", "hacked with read access") + if err == nil { + t.Error("bob should not be able to update alice's node with only read access") + } + }) + + t.Run("ReadAccess_CannotAddRelationToNode", func(t *testing.T) { + bobLinkedID := bob.parseNode(bob.mustAx("add", "Bob's linked node", "--json")).ID + _, err := bob.ax("update", bobLinkedID, "--rel", "related:"+aliceNodeID) + if err == nil { + t.Error("bob should not be able to create a relation to alice's node with only read access") + } + }) + + t.Run("WriteAccess_Grant", func(t *testing.T) { + alice.mustAx("update", bobUserID, "--rel", "can_write:"+aliceNodeID) + + out := bob.mustAx("update", aliceNodeID, "--title", "Bob modified this", "--json") + n := bob.parseNode(out) + if n.Title != "Bob modified this" { + t.Errorf("expected 'Bob modified this', got %q", n.Title) + } + }) + + t.Run("WriteAccess_CanAddRelationToNode", func(t *testing.T) { + bobNode2 := bob.parseNode(bob.mustAx("add", "Bob's related node", "--json")) + bob.mustAx("update", bobNode2.ID, "--rel", "related:"+aliceNodeID) + out := bob.mustAx("show", bobNode2.ID, "--json") + n := bob.parseNode(out) + if !n.HasRelation("related", aliceNodeID) { + t.Error("expected related relation after write access granted") + } + }) + + t.Run("Ownership_DefaultOnCreate", func(t *testing.T) { + userOut := alice.mustAx("show", aliceUserID, "--json") + userNode := alice.parseNode(userOut) + if !userNode.HasRelation("has_ownership", aliceUserID) { + t.Errorf("expected user node to have self-ownership, got relations: %v", userNode.Relations) + } + if !userNode.HasRelation("has_ownership", aliceNodeID) { + t.Errorf("expected alice to own her node, got relations: %v", userNode.Relations) + } + }) + + t.Run("Ownership_CascadeDelete", func(t *testing.T) { + throwaway := alice.withUser("throwaway") + child := throwaway.parseNode(throwaway.mustAx("add", "Child node", "--json")) + + var throwawayUserID string + for _, u := range throwaway.parseNodes(throwaway.mustAx("list", "--type", "user", "--json")) { + if u.Title == "throwaway" { + throwawayUserID = u.ID + } + } + if throwawayUserID == "" { + t.Fatal("could not find throwaway user node") + } + + throwaway.mustAx("del", throwawayUserID, "--force") + + _, err := throwaway.ax("show", child.ID) + if err == nil { + t.Error("child node should have been cascade-deleted when its owner was deleted") + } + }) +} + +func TestNamespaceExplicitCreate(t *testing.T) { + env := newTestEnv(t, "testuser") + + nsNode := env.parseNode(env.mustAx("add", "myworkspace", "--type", "namespace", "--json")) + + if !nsNode.HasRelation("in_namespace", nsNode.ID) { + t.Errorf("expected namespace to have in_namespace pointing to itself, got relations: %v", nsNode.Relations) + } + + users := env.parseNodes(env.mustAx("list", "--type", "user", "--json")) + var userNode *NodeResponse + for i := range users { + if users[i].Title == "testuser" { + userNode = &users[i] + break + } + } + if userNode == nil { + t.Fatal("could not find testuser node") + } + if !userNode.HasRelation("has_ownership", nsNode.ID) { + t.Errorf("expected creator to have has_ownership on new namespace, got relations: %v", userNode.Relations) + } + + env.mustAx("add", "task in workspace", "--namespace", "myworkspace", "--json") + listed := env.parseNodes(env.mustAx("list", "--namespace", "myworkspace", "--json")) + if len(listed) == 0 { + t.Error("expected to list nodes in newly created namespace") + } +} diff --git a/src/e2e_test.go b/src/e2e_test.go index cae2d67..0091023 100644 --- a/src/e2e_test.go +++ b/src/e2e_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" @@ -23,7 +24,7 @@ type NodeResponse struct { Relations map[string][]string `json:"relations"` } -func (n NodeResponse) HasTag(tag string) bool { return slices.Contains(n.Tags, tag) } +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 { @@ -37,31 +38,57 @@ func (n NodeResponse) HasRelation(relType, targetID string) bool { return slices.Contains(n.Relations[relType], targetID) } +// ── Test binary ─────────────────────────────────────────────────────────────── + +var axBinary string + +func TestMain(m *testing.M) { + if err := exec.Command("go", "build", "-o", "ax", ".").Run(); err != nil { + fmt.Fprintln(os.Stderr, "build failed:", err) + os.Exit(1) + } + abs, err := filepath.Abs("./ax") + if err != nil { + fmt.Fprintln(os.Stderr, "resolve binary path:", err) + os.Exit(1) + } + axBinary = abs + code := m.Run() + os.Remove("./ax") + os.Exit(code) +} + // ── Test environment ────────────────────────────────────────────────────────── type testEnv struct { t *testing.T dir string - user string // if non-empty, overrides AX_USER for every command + user string } -// envWithUser returns the current process environment with AX_USER replaced. -func envWithUser(user string) []string { - var filtered []string - for _, e := range os.Environ() { - if !strings.HasPrefix(e, "AX_USER=") { - filtered = append(filtered, e) - } - } - return append(filtered, "AX_USER="+user) +func newTestEnv(t *testing.T, user string) *testEnv { + t.Helper() + env := &testEnv{t: t, dir: t.TempDir(), user: user} + env.mustAx("init") + return env +} + +// withUser returns a new testEnv pointing at the same directory but with a different user. +func (e *testEnv) withUser(user string) *testEnv { + return &testEnv{t: e.t, dir: e.dir, user: user} } func (e *testEnv) ax(args ...string) (string, error) { - cmd := exec.Command("./ax", args...) + cmd := exec.Command(axBinary, args...) cmd.Dir = e.dir - if e.user != "" { - cmd.Env = envWithUser(e.user) + var env []string + for _, v := range os.Environ() { + if !strings.HasPrefix(v, "AX_USER=") { + env = append(env, v) + } } + env = append(env, "AX_USER="+e.user) + cmd.Env = env out, err := cmd.CombinedOutput() return string(out), err } @@ -79,20 +106,19 @@ 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) + e.t.Fatalf("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) + e.t.Fatalf("parse node list JSON: %v\noutput: %s", err, out) } return nodes } @@ -105,926 +131,3 @@ func (e *testEnv) findInList(nodes []NodeResponse, id string) (NodeResponse, boo } return NodeResponse{}, false } - -// ── Test setup ──────────────────────────────────────────────────────────────── - -func TestE2E(t *testing.T) { - // 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") - - tmpDir, err := os.MkdirTemp("", "ax-e2e-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - if err := exec.Command("cp", "./ax", filepath.Join(tmpDir, "ax")).Run(); err != nil { - t.Fatalf("failed to copy binary: %v", err) - } - - t.Setenv("AX_USER", "testuser") - - // ── Init ───────────────────────────────────────────────────────────────── - - t.Run("Init", func(t *testing.T) { - env := &testEnv{t: t, dir: tmpDir} - env.mustAx("init") - - if _, err := os.Stat(filepath.Join(tmpDir, ".ax.db")); err != nil { - t.Fatal(".ax.db not created") - } - }) - - 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") - } - }) - - // ── Add ────────────────────────────────────────────────────────────────── - - // Shared node IDs used across the remaining subtests. - var issueID, issue2ID, noteID string - - 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 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") - } - }) - - 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 - - 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") - } - }) - - 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 - - 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 - }) - - // ── Permissions ─────────────────────────────────────────────────────────── - - t.Run("Permissions", func(t *testing.T) { - permDir, err := os.MkdirTemp("", "ax-perm-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(permDir) - if err := exec.Command("cp", "./ax", filepath.Join(permDir, "ax")).Run(); err != nil { - t.Fatal(err) - } - - alice := &testEnv{t: t, dir: permDir, user: "alice"} - bob := &testEnv{t: t, dir: permDir, user: "bob"} - - alice.mustAx("init") - - // Alice creates a node (she gets has_ownership on it automatically). - aliceNode := alice.parseNode(alice.mustAx("add", "Alice's secret", "--json")) - aliceNodeID := aliceNode.ID - - // Resolve alice's user node ID. - var aliceUserID string - for _, u := range alice.parseNodes(alice.mustAx("list", "--type", "user", "--json")) { - if u.Title == "alice" { - aliceUserID = u.ID - } - } - if aliceUserID == "" { - t.Fatal("could not resolve alice's user node ID") - } - - t.Run("NoAccess_CannotShow", func(t *testing.T) { - _, err := bob.ax("show", aliceNodeID) - if err == nil { - t.Error("bob should not be able to show alice's node without access") - } - }) - - t.Run("NoAccess_NotInList", func(t *testing.T) { - // Bob bootstraps by creating his own node first. - bob.mustAx("add", "Bob's scratch", "--json") - - nodes := bob.parseNodes(bob.mustAx("list", "--json")) - if _, ok := bob.findInList(nodes, aliceNodeID); ok { - t.Error("alice's node should not appear in bob's list without access") - } - }) - - t.Run("NoAccess_CannotUpdate", func(t *testing.T) { - _, err := bob.ax("update", aliceNodeID, "--title", "hacked") - if err == nil { - t.Error("bob should not be able to update alice's node without access") - } - }) - - // Resolve bob's user node ID. User nodes are globally readable. - var bobUserID string - for _, u := range alice.parseNodes(alice.mustAx("list", "--type", "user", "--json")) { - if u.Title == "bob" { - bobUserID = u.ID - } - } - if bobUserID == "" { - t.Fatal("could not resolve bob's user node ID") - } - - t.Run("SelfEscalation_Denied", func(t *testing.T) { - // Bob attempts to grant himself can_write on alice's node. - // Bob has no permissions on aliceNode, so this must fail. - _, err := bob.ax("update", bobUserID, "--rel", "can_write:"+aliceNodeID) - if err == nil { - t.Error("bob should not be able to grant himself write access to alice's node") - } - }) - - t.Run("ReadAccess_Grant", func(t *testing.T) { - // Alice grants bob can_read on her node. - // Alice has has_ownership on aliceNode (level 4 >= can_read level 1) so this succeeds. - alice.mustAx("update", bobUserID, "--rel", "can_read:"+aliceNodeID) - - // Bob can now show alice's node. - if _, err := bob.ax("show", aliceNodeID); err != nil { - t.Error("bob should be able to show alice's node after read access granted") - } - // And it appears in his list. - nodes := bob.parseNodes(bob.mustAx("list", "--json")) - if _, ok := bob.findInList(nodes, aliceNodeID); !ok { - t.Error("alice's node should appear in bob's list after read access granted") - } - }) - - t.Run("ReadAccess_CannotUpdate", func(t *testing.T) { - _, err := bob.ax("update", aliceNodeID, "--title", "hacked with read access") - if err == nil { - t.Error("bob should not be able to update alice's node with only read access") - } - }) - - t.Run("ReadAccess_CannotAddRelationToNode", func(t *testing.T) { - // Bob creates his own node and tries to link it to alice's node. - // Requires can_create_rel on BOTH nodes; bob only has can_read on alice's node. - bobLinkedID := bob.parseNode(bob.mustAx("add", "Bob's linked node", "--json")).ID - _, err := bob.ax("update", bobLinkedID, "--rel", "related:"+aliceNodeID) - if err == nil { - t.Error("bob should not be able to create a relation to alice's node with only read access") - } - }) - - t.Run("WriteAccess_Grant", func(t *testing.T) { - // Alice grants bob can_write on her node. - // Alice has has_ownership (level 4 >= can_write level 3) so this succeeds. - alice.mustAx("update", bobUserID, "--rel", "can_write:"+aliceNodeID) - - out := bob.mustAx("update", aliceNodeID, "--title", "Bob modified this", "--json") - n := bob.parseNode(out) - if n.Title != "Bob modified this" { - t.Errorf("expected 'Bob modified this', got %q", n.Title) - } - }) - - t.Run("WriteAccess_CanAddRelationToNode", func(t *testing.T) { - // Bob creates a node and links it to alice's node (now has can_write → can_create_rel). - bobNode2 := bob.parseNode(bob.mustAx("add", "Bob's related node", "--json")) - bob.mustAx("update", bobNode2.ID, "--rel", "related:"+aliceNodeID) - out := bob.mustAx("show", bobNode2.ID, "--json") - n := bob.parseNode(out) - if !n.HasRelation("related", aliceNodeID) { - t.Error("expected related relation after write access granted") - } - }) - - t.Run("Ownership_DefaultOnCreate", func(t *testing.T) { - // Verify alice's user node has self-ownership. - userOut := alice.mustAx("show", aliceUserID, "--json") - userNode := alice.parseNode(userOut) - if !userNode.HasRelation("has_ownership", aliceUserID) { - t.Errorf("expected user node to have self-ownership, got relations: %v", userNode.Relations) - } - // Verify alice owns her issue node. - if !userNode.HasRelation("has_ownership", aliceNodeID) { - t.Errorf("expected alice to own her node, got relations: %v", userNode.Relations) - } - }) - - t.Run("Ownership_CascadeDelete", func(t *testing.T) { - // Alice creates a child node. Alice owns both. - // Deleting alice's user node should cascade and delete the child. - // (We use a throwaway user to avoid breaking other subtests.) - throwaway := &testEnv{t: t, dir: permDir, user: "throwaway"} - child := throwaway.parseNode(throwaway.mustAx("add", "Child node", "--json")) - - // Resolve throwaway's user node. - var throwawayUserID string - for _, u := range throwaway.parseNodes(throwaway.mustAx("list", "--type", "user", "--json")) { - if u.Title == "throwaway" { - throwawayUserID = u.ID - } - } - if throwawayUserID == "" { - t.Fatal("could not find throwaway user node") - } - - // Delete throwaway's user node — should cascade to child. - throwaway.mustAx("del", throwawayUserID, "--force") - - // Child should be gone. - _, err := throwaway.ax("show", child.ID) - if err == nil { - t.Error("child node should have been cascade-deleted when its owner was deleted") - } - }) - }) - - t.Run("Namespace_ExplicitCreate", func(t *testing.T) { - nsDir, err := os.MkdirTemp("", "ax-ns-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(nsDir) - if err := exec.Command("cp", "./ax", filepath.Join(nsDir, "ax")).Run(); err != nil { - t.Fatal(err) - } - env := &testEnv{t: t, dir: nsDir, user: "testuser"} - env.mustAx("init") - - // Explicitly create a namespace node via --type namespace. - nsNode := env.parseNode(env.mustAx("add", "myworkspace", "--type", "namespace", "--json")) - - // The namespace should be in its own namespace (self-reference). - if !nsNode.HasRelation("in_namespace", nsNode.ID) { - t.Errorf("expected namespace to have in_namespace pointing to itself, got relations: %v", nsNode.Relations) - } - - // The creator should have write access to the new namespace. - users := env.parseNodes(env.mustAx("list", "--type", "user", "--json")) - var userNode *NodeResponse - for i := range users { - if users[i].Title == "testuser" { - userNode = &users[i] - break - } - } - if userNode == nil { - t.Fatal("could not find testuser node") - } - if !userNode.HasRelation("has_ownership", nsNode.ID) { - t.Errorf("expected creator to have has_ownership on new namespace, got relations: %v", userNode.Relations) - } - - // Nodes added to the new namespace should be accessible. - env.mustAx("add", "task in workspace", "--namespace", "myworkspace", "--json") - listed := env.parseNodes(env.mustAx("list", "--namespace", "myworkspace", "--json")) - if len(listed) == 0 { - t.Error("expected to list nodes in newly created namespace") - } - }) -} diff --git a/src/models/node.go b/src/models/node.go index 3ba2249..0f519e5 100644 --- a/src/models/node.go +++ b/src/models/node.go @@ -1,15 +1,48 @@ package models import ( + "encoding/json" + "fmt" "slices" "strings" + "time" ) +// Date is a date-only time value that marshals as "YYYY-MM-DD" in JSON. +type Date struct{ time.Time } + +// ParseDate parses a date string in "YYYY-MM-DD" or RFC3339 format. +func ParseDate(s string) (Date, error) { + for _, layout := range []string{"2006-01-02", time.RFC3339} { + if t, err := time.Parse(layout, s); err == nil { + return Date{t.UTC()}, nil + } + } + return Date{}, fmt.Errorf("cannot parse date %q: expected YYYY-MM-DD", s) +} + +func (d Date) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Format("2006-01-02")) +} + +func (d *Date) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + parsed, err := ParseDate(s) + if err != nil { + return err + } + *d = parsed + return nil +} + type Node struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content,omitempty"` - DueDate string `json:"due_date,omitempty"` + DueDate *Date `json:"due_date,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Tags []string `json:"tags,omitempty"` @@ -85,7 +118,9 @@ func (n *Node) RemoveRelation(relType RelType, target string) { func (n *Node) GetProperty(k string) string { prefix := "_" + k + "::" for _, t := range n.Tags { - return strings.TrimPrefix(t, prefix) + if strings.HasPrefix(t, prefix) { + return strings.TrimPrefix(t, prefix) + } } return "" } diff --git a/src/service/node_service_impl.go b/src/service/node_service_impl.go index 1b67198..0ea1d58 100644 --- a/src/service/node_service_impl.go +++ b/src/service/node_service_impl.go @@ -18,6 +18,21 @@ type nodeServiceImpl struct { var mentionRegex = regexp.MustCompile(`@([a-z0-9_]+)`) +// parseDueDate converts a user-supplied date string to *time.Time. +// Returns nil, nil for an empty string (clear/unset). Accepts "YYYY-MM-DD" or RFC3339. +func parseDueDate(s string) (*time.Time, error) { + if s == "" { + return nil, nil + } + for _, layout := range []string{"2006-01-02", time.RFC3339} { + if t, err := time.Parse(layout, s); err == nil { + ut := t.UTC() + return &ut, nil + } + } + return nil, fmt.Errorf("invalid due date %q: expected YYYY-MM-DD", s) +} + func mentions(t string) []string { seen := make(map[string]bool) for _, m := range mentionRegex.FindAllStringSubmatch(t, -1) { @@ -295,6 +310,11 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { } } + dueDate, err := parseDueDate(input.DueDate) + if err != nil { + return nil, err + } + id, err := s.store.GenerateID() if err != nil { return nil, err @@ -302,7 +322,7 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { err = s.store.Transaction(func(st store.GraphStore) error { now := time.Now().UTC().Format(time.RFC3339) - if err := st.AddNode(id, input.Title, input.Content, input.DueDate, now, now); err != nil { + if err := st.AddNode(id, input.Title, input.Content, dueDate, now, now); err != nil { return err } @@ -492,7 +512,12 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er return err } - title, content, dueDate := current.Title, current.Content, current.DueDate + title, content := current.Title, current.Content + var dueDate *time.Time + if current.DueDate != nil { + t := current.DueDate.Time + dueDate = &t + } if input.Title != nil { title = *input.Title } @@ -500,7 +525,11 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er content = *input.Content } if input.DueDate != nil { - dueDate = *input.DueDate + parsed, err := parseDueDate(*input.DueDate) + if err != nil { + return err + } + dueDate = parsed } now := time.Now().UTC().Format(time.RFC3339) @@ -774,7 +803,7 @@ func (s *nodeServiceImpl) ensureUser(st store.GraphStore, username string) (stri return "", err } now := time.Now().UTC().Format(time.RFC3339) - if err := st.AddNode(id, username, "", "", now, now); err != nil { + if err := st.AddNode(id, username, "", nil, now, now); err != nil { return "", err } if err := st.AddRel(id, "_type::user", ""); err != nil { @@ -807,7 +836,7 @@ func (s *nodeServiceImpl) ensureNamespace(st store.GraphStore, name string) (str return "", err } now := time.Now().UTC().Format(time.RFC3339) - if err := st.AddNode(id, name, "", "", now, now); err != nil { + if err := st.AddNode(id, name, "", nil, now, now); err != nil { return "", err } if err := st.AddRel(id, "_type::namespace", ""); err != nil { diff --git a/src/store/config.go b/src/store/config.go index 1372c08..47c3a67 100644 --- a/src/store/config.go +++ b/src/store/config.go @@ -3,12 +3,28 @@ package store import ( "encoding/json" "errors" + "fmt" "os" "os/user" "path/filepath" "slices" ) +var builtinAliases = []*Alias{ + {Name: "mine", Command: "list --assignee $me", Description: "My assigned issues"}, + {Name: "due", Command: "list --status open", Description: "Open issues"}, + {Name: "inbox", Command: "list --mention $me", Description: "My mentions"}, +} + +func isBuiltinAlias(name string) bool { + for _, a := range builtinAliases { + if a.Name == name { + return true + } + } + return false +} + type Alias struct { Name string `json:"name"` Command string `json:"command"` @@ -128,6 +144,9 @@ func (c *Config) SetAlias(alias *Alias) error { } func (c *Config) DeleteAlias(name string) error { + if isBuiltinAlias(name) { + return fmt.Errorf("cannot delete built-in alias %q", name) + } for i, a := range c.UserAliases { if a.Name == name { c.UserAliases = slices.Delete(c.UserAliases, i, i+1) @@ -140,10 +159,16 @@ func (c *Config) DeleteAlias(name string) error { func (c *Config) ListAliases() ([]*Alias, error) { seen := make(map[string]bool) var result []*Alias - for _, a := range c.UserAliases { + for _, a := range builtinAliases { result = append(result, a) seen[a.Name] = true } + for _, a := range c.UserAliases { + if !seen[a.Name] { + result = append(result, a) + seen[a.Name] = true + } + } return result, nil } diff --git a/src/store/graph_store.go b/src/store/graph_store.go index 0e9ec32..63ee2b7 100644 --- a/src/store/graph_store.go +++ b/src/store/graph_store.go @@ -1,15 +1,18 @@ package store -import "axolotl/models" +import ( + "axolotl/models" + "time" +) // GraphStore is a primitive graph persistence interface. It provides basic // operations for nodes and directed rels. No business logic lives here. // "Tag" rels are rels with an empty toID (e.g. "_type::issue" or "backend"). type GraphStore interface { // Nodes - AddNode(id, title, content, dueDate, createdAt, updatedAt string) error + AddNode(id, title, content string, dueDate *time.Time, createdAt, updatedAt string) error GetNode(id string) (*models.Node, error) // returns node with tags and rels populated - UpdateNode(id, title, content, dueDate, updatedAt string) error // empty dueDate stores NULL + UpdateNode(id, title, content string, dueDate *time.Time, updatedAt string) error // nil dueDate stores NULL DeleteNode(id string) error NodeExists(id string) (bool, error) GenerateID() (string, error) // returns a random 5-char ID guaranteed unique in the store diff --git a/src/store/graph_store_sqlite.go b/src/store/graph_store_sqlite.go index b3c43e1..f9b5abf 100644 --- a/src/store/graph_store_sqlite.go +++ b/src/store/graph_store_sqlite.go @@ -8,12 +8,13 @@ import ( "os" "path/filepath" "strings" + "time" _ "modernc.org/sqlite" ) var migrations = []string{ - `CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, due_date TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`, + `CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, due_date DATETIME, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`, `CREATE TABLE IF NOT EXISTS rels (from_id TEXT NOT NULL, rel_name TEXT NOT NULL, to_id TEXT NOT NULL DEFAULT '', PRIMARY KEY (from_id, rel_name, to_id), FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE)`, `CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`, `CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`, @@ -129,6 +130,10 @@ func NewSQLiteStore(path string) (GraphStore, error) { db.Close() return nil, fmt.Errorf("schema migration failed: %w", err) } + if err := migrateDueDateColumn(db); err != nil { + db.Close() + return nil, fmt.Errorf("due_date column migration failed: %w", err) + } if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { db.Close() return nil, err @@ -163,6 +168,44 @@ func migrateSchema(db *sql.DB) error { return nil } +// migrateDueDateColumn converts the due_date column from TEXT to DATETIME affinity +// for databases created before this schema change. It is a no-op when already migrated. +func migrateDueDateColumn(db *sql.DB) error { + rows, err := db.Query("PRAGMA table_info(nodes)") + if err != nil { + return err + } + defer rows.Close() + var colType string + for rows.Next() { + var cid, notNull, pk int + var name, typ string + var dfltVal any + if err := rows.Scan(&cid, &name, &typ, ¬Null, &dfltVal, &pk); err != nil { + return err + } + if name == "due_date" { + colType = strings.ToUpper(typ) + break + } + } + rows.Close() + if colType == "DATETIME" || colType == "" { + return nil // already on new schema or column missing + } + for _, stmt := range []string{ + `CREATE TABLE nodes_new (id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, due_date DATETIME, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`, + `INSERT INTO nodes_new SELECT id, title, content, due_date, created_at, updated_at FROM nodes`, + `DROP TABLE nodes`, + `ALTER TABLE nodes_new RENAME TO nodes`, + } { + if _, err := db.Exec(stmt); err != nil { + return err + } + } + return nil +} + // --- Transaction --- func (s *GraphStoreSqlite) Transaction(fn func(GraphStore) error) error { @@ -183,10 +226,10 @@ func (s *txStore) Transaction(fn func(GraphStore) error) error { // --- Node operations --- -func addNode(q querier, id, title, content, dueDate, createdAt, updatedAt string) error { +func addNode(q querier, id, title, content string, dueDate *time.Time, createdAt, updatedAt string) error { var dd any - if dueDate != "" { - dd = dueDate + if dueDate != nil { + dd = dueDate.UTC().Format("2006-01-02") } _, err := q.Exec( "INSERT INTO nodes (id, title, content, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", @@ -197,12 +240,18 @@ func addNode(q querier, id, title, content, dueDate, createdAt, updatedAt string func getNode(q querier, id string) (*models.Node, error) { n := models.NewNode() + var dueDateStr string err := q.QueryRow( "SELECT id, title, COALESCE(content, ''), COALESCE(due_date, ''), created_at, updated_at FROM nodes WHERE id = ?", id, - ).Scan(&n.ID, &n.Title, &n.Content, &n.DueDate, &n.CreatedAt, &n.UpdatedAt) + ).Scan(&n.ID, &n.Title, &n.Content, &dueDateStr, &n.CreatedAt, &n.UpdatedAt) if err != nil { return nil, err } + if dueDateStr != "" { + if d, err := models.ParseDate(dueDateStr); err == nil { + n.DueDate = &d + } + } rows, err := q.Query("SELECT rel_name, to_id FROM rels WHERE from_id = ?", id) if err != nil { return nil, err @@ -220,10 +269,10 @@ func getNode(q querier, id string) (*models.Node, error) { return n, nil } -func updateNode(q querier, id, title, content, dueDate, updatedAt string) error { +func updateNode(q querier, id, title, content string, dueDate *time.Time, updatedAt string) error { var dd any - if dueDate != "" { - dd = dueDate + if dueDate != nil { + dd = dueDate.UTC().Format("2006-01-02") } _, err := q.Exec( "UPDATE nodes SET title = ?, content = ?, due_date = ?, updated_at = ? WHERE id = ?", @@ -243,21 +292,21 @@ func nodeExists(q querier, id string) (bool, error) { return e, err } -func (s *GraphStoreSqlite) AddNode(id, title, content, dueDate, createdAt, updatedAt string) error { +func (s *GraphStoreSqlite) AddNode(id, title, content string, dueDate *time.Time, createdAt, updatedAt string) error { return addNode(s.db, id, title, content, dueDate, createdAt, updatedAt) } func (s *GraphStoreSqlite) GetNode(id string) (*models.Node, error) { return getNode(s.db, id) } -func (s *GraphStoreSqlite) UpdateNode(id, title, content, dueDate, updatedAt string) error { +func (s *GraphStoreSqlite) UpdateNode(id, title, content string, dueDate *time.Time, updatedAt string) error { return updateNode(s.db, id, title, content, dueDate, updatedAt) } func (s *GraphStoreSqlite) DeleteNode(id string) error { return deleteNode(s.db, id) } func (s *GraphStoreSqlite) NodeExists(id string) (bool, error) { return nodeExists(s.db, id) } -func (s *txStore) AddNode(id, title, content, dueDate, createdAt, updatedAt string) error { +func (s *txStore) AddNode(id, title, content string, dueDate *time.Time, createdAt, updatedAt string) error { return addNode(s.tx, id, title, content, dueDate, createdAt, updatedAt) } func (s *txStore) GetNode(id string) (*models.Node, error) { return getNode(s.tx, id) } -func (s *txStore) UpdateNode(id, title, content, dueDate, updatedAt string) error { +func (s *txStore) UpdateNode(id, title, content string, dueDate *time.Time, updatedAt string) error { return updateNode(s.tx, id, title, content, dueDate, updatedAt) } func (s *txStore) DeleteNode(id string) error { return deleteNode(s.tx, id) }