fix: correct GetProperty bug, init to use .ax/, add default aliases, split e2e tests, add due date tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 01:58:48 +02:00
parent 5969a2591c
commit 921f4913f8
13 changed files with 1121 additions and 971 deletions

View File

@@ -12,14 +12,17 @@ import (
var initCmd = &cobra.Command{ var initCmd = &cobra.Command{
Use: "init [path]", Short: "Initialize a new database", Args: cobra.MaximumNArgs(1), Use: "init [path]", Short: "Initialize a new database", Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
dataRoot, err := store.FindDataRoot(".local", "share") var dbPath string
if len(args) > 0 { if len(args) > 0 {
dataRoot = args[0] dbPath = filepath.Join(args[0], "ax.db")
} else if err != nil { } else {
fmt.Fprintln(os.Stderr, "failed to find data dir:", err) cwd, err := filepath.Abs(".")
if err != nil {
fmt.Fprintln(os.Stderr, "failed to get working directory:", err)
os.Exit(1) os.Exit(1)
} }
dbPath := filepath.Join(dataRoot, "ax.db") dbPath = filepath.Join(cwd, ".ax", "ax.db")
}
if _, err := os.Stat(dbPath); err == nil { if _, err := os.Stat(dbPath); err == nil {
fmt.Fprintln(os.Stderr, "database already exists:", dbPath) fmt.Fprintln(os.Stderr, "database already exists:", dbPath)
os.Exit(1) os.Exit(1)

View File

@@ -116,8 +116,8 @@ func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut boo
fmt.Fprintln(w, cDim.Sprint(" ───────────────────────────────")) fmt.Fprintln(w, cDim.Sprint(" ───────────────────────────────"))
fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false)) fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false))
fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false)) fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false))
if n.DueDate != "" { if n.DueDate != nil {
fmt.Fprintf(w, " Due: %s %s\n", iconCalendar, n.DueDate) 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, " Created: %s\n", cDim.Sprint(n.CreatedAt))
fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt)) fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt))

67
src/e2e_aliases_test.go Normal file
View File

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

549
src/e2e_crud_test.go Normal file
View File

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

85
src/e2e_duedate_test.go Normal file
View File

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

25
src/e2e_init_test.go Normal file
View File

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

177
src/e2e_permissions_test.go Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,48 @@
package models package models
import ( import (
"encoding/json"
"fmt"
"slices" "slices"
"strings" "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 { type Node struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
DueDate string `json:"due_date,omitempty"` DueDate *Date `json:"due_date,omitempty"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
@@ -85,8 +118,10 @@ func (n *Node) RemoveRelation(relType RelType, target string) {
func (n *Node) GetProperty(k string) string { func (n *Node) GetProperty(k string) string {
prefix := "_" + k + "::" prefix := "_" + k + "::"
for _, t := range n.Tags { for _, t := range n.Tags {
if strings.HasPrefix(t, prefix) {
return strings.TrimPrefix(t, prefix) return strings.TrimPrefix(t, prefix)
} }
}
return "" return ""
} }

View File

@@ -18,6 +18,21 @@ type nodeServiceImpl struct {
var mentionRegex = regexp.MustCompile(`@([a-z0-9_]+)`) 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 { func mentions(t string) []string {
seen := make(map[string]bool) seen := make(map[string]bool)
for _, m := range mentionRegex.FindAllStringSubmatch(t, -1) { 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() id, err := s.store.GenerateID()
if err != nil { if err != nil {
return nil, err 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 { err = s.store.Transaction(func(st store.GraphStore) error {
now := time.Now().UTC().Format(time.RFC3339) 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 return err
} }
@@ -492,7 +512,12 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
return err 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 { if input.Title != nil {
title = *input.Title title = *input.Title
} }
@@ -500,7 +525,11 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
content = *input.Content content = *input.Content
} }
if input.DueDate != nil { 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) now := time.Now().UTC().Format(time.RFC3339)
@@ -774,7 +803,7 @@ func (s *nodeServiceImpl) ensureUser(st store.GraphStore, username string) (stri
return "", err return "", err
} }
now := time.Now().UTC().Format(time.RFC3339) 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 return "", err
} }
if err := st.AddRel(id, "_type::user", ""); err != nil { 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 return "", err
} }
now := time.Now().UTC().Format(time.RFC3339) 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 return "", err
} }
if err := st.AddRel(id, "_type::namespace", ""); err != nil { if err := st.AddRel(id, "_type::namespace", ""); err != nil {

View File

@@ -3,12 +3,28 @@ package store
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"os" "os"
"os/user" "os/user"
"path/filepath" "path/filepath"
"slices" "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 { type Alias struct {
Name string `json:"name"` Name string `json:"name"`
Command string `json:"command"` Command string `json:"command"`
@@ -128,6 +144,9 @@ func (c *Config) SetAlias(alias *Alias) error {
} }
func (c *Config) DeleteAlias(name string) 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 { for i, a := range c.UserAliases {
if a.Name == name { if a.Name == name {
c.UserAliases = slices.Delete(c.UserAliases, i, i+1) 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) { func (c *Config) ListAliases() ([]*Alias, error) {
seen := make(map[string]bool) seen := make(map[string]bool)
var result []*Alias var result []*Alias
for _, a := range c.UserAliases { for _, a := range builtinAliases {
result = append(result, a) result = append(result, a)
seen[a.Name] = true seen[a.Name] = true
} }
for _, a := range c.UserAliases {
if !seen[a.Name] {
result = append(result, a)
seen[a.Name] = true
}
}
return result, nil return result, nil
} }

View File

@@ -1,15 +1,18 @@
package store package store
import "axolotl/models" import (
"axolotl/models"
"time"
)
// GraphStore is a primitive graph persistence interface. It provides basic // GraphStore is a primitive graph persistence interface. It provides basic
// operations for nodes and directed rels. No business logic lives here. // 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"). // "Tag" rels are rels with an empty toID (e.g. "_type::issue" or "backend").
type GraphStore interface { type GraphStore interface {
// Nodes // 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 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 DeleteNode(id string) error
NodeExists(id string) (bool, error) NodeExists(id string) (bool, error)
GenerateID() (string, error) // returns a random 5-char ID guaranteed unique in the store GenerateID() (string, error) // returns a random 5-char ID guaranteed unique in the store

View File

@@ -8,12 +8,13 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
var migrations = []string{ 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 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_from ON rels(from_id)`,
`CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_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() db.Close()
return nil, fmt.Errorf("schema migration failed: %w", err) 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 { if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
db.Close() db.Close()
return nil, err return nil, err
@@ -163,6 +168,44 @@ func migrateSchema(db *sql.DB) error {
return nil 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, &notNull, &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 --- // --- Transaction ---
func (s *GraphStoreSqlite) Transaction(fn func(GraphStore) error) error { func (s *GraphStoreSqlite) Transaction(fn func(GraphStore) error) error {
@@ -183,10 +226,10 @@ func (s *txStore) Transaction(fn func(GraphStore) error) error {
// --- Node operations --- // --- 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 var dd any
if dueDate != "" { if dueDate != nil {
dd = dueDate dd = dueDate.UTC().Format("2006-01-02")
} }
_, err := q.Exec( _, err := q.Exec(
"INSERT INTO nodes (id, title, content, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", "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) { func getNode(q querier, id string) (*models.Node, error) {
n := models.NewNode() n := models.NewNode()
var dueDateStr string
err := q.QueryRow( err := q.QueryRow(
"SELECT id, title, COALESCE(content, ''), COALESCE(due_date, ''), created_at, updated_at FROM nodes WHERE id = ?", id, "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 { if err != nil {
return nil, err 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) rows, err := q.Query("SELECT rel_name, to_id FROM rels WHERE from_id = ?", id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -220,10 +269,10 @@ func getNode(q querier, id string) (*models.Node, error) {
return n, nil 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 var dd any
if dueDate != "" { if dueDate != nil {
dd = dueDate dd = dueDate.UTC().Format("2006-01-02")
} }
_, err := q.Exec( _, err := q.Exec(
"UPDATE nodes SET title = ?, content = ?, due_date = ?, updated_at = ? WHERE id = ?", "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 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) 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) 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) return updateNode(s.db, id, title, content, dueDate, updatedAt)
} }
func (s *GraphStoreSqlite) DeleteNode(id string) error { return deleteNode(s.db, id) } 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 *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) 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) 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) return updateNode(s.tx, id, title, content, dueDate, updatedAt)
} }
func (s *txStore) DeleteNode(id string) error { return deleteNode(s.tx, id) } func (s *txStore) DeleteNode(id string) error { return deleteNode(s.tx, id) }