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:
@@ -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)
|
||||
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(dataRoot, "ax.db")
|
||||
dbPath = filepath.Join(cwd, ".ax", "ax.db")
|
||||
}
|
||||
if _, err := os.Stat(dbPath); err == nil {
|
||||
fmt.Fprintln(os.Stderr, "database already exists:", dbPath)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -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))
|
||||
|
||||
67
src/e2e_aliases_test.go
Normal file
67
src/e2e_aliases_test.go
Normal 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
549
src/e2e_crud_test.go
Normal 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
85
src/e2e_duedate_test.go
Normal 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
25
src/e2e_init_test.go
Normal 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
177
src/e2e_permissions_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
979
src/e2e_test.go
979
src/e2e_test.go
File diff suppressed because it is too large
Load Diff
@@ -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,8 +118,10 @@ func (n *Node) RemoveRelation(relType RelType, target string) {
|
||||
func (n *Node) GetProperty(k string) string {
|
||||
prefix := "_" + k + "::"
|
||||
for _, t := range n.Tags {
|
||||
if strings.HasPrefix(t, prefix) {
|
||||
return strings.TrimPrefix(t, prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user