package kg import ( "context" "database/sql" "path/filepath" "testing" "time" "git.wntrmute.dev/kyle/exo/artifacts" "git.wntrmute.dev/kyle/exo/core" "git.wntrmute.dev/kyle/exo/db" ) func mustOpenAndMigrate(t *testing.T) *sql.DB { t.Helper() path := filepath.Join(t.TempDir(), "test.db") database, err := db.Open(path) if err != nil { t.Fatalf("Open failed: %v", err) } t.Cleanup(func() { _ = database.Close() }) if err := db.Migrate(database); err != nil { t.Fatalf("Migrate failed: %v", err) } return database } func mustTX(t *testing.T, database *sql.DB) (*sql.Tx, context.Context) { t.Helper() ctx := context.Background() tx, err := db.StartTX(ctx, database) if err != nil { t.Fatalf("StartTX failed: %v", err) } return tx, ctx } // --- Node tests --- func TestNodeStoreAndGet(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) node := NewNode("TestNote", NodeTypeNote) if err := node.Store(ctx, tx); err != nil { t.Fatalf("Node.Store failed: %v", err) } got := &Node{ID: node.ID} if err := got.Get(ctx, tx); err != nil { t.Fatalf("Node.Get failed: %v", err) } if got.Name != "TestNote" { t.Fatalf("name mismatch: got %q", got.Name) } if got.Type != NodeTypeNote { t.Fatalf("type mismatch: got %q", got.Type) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestNodeHierarchy(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) parent := NewNode("ParentNote", NodeTypeNote) if err := parent.Store(ctx, tx); err != nil { t.Fatalf("parent Store failed: %v", err) } child := NewNode("ChildNote", NodeTypeNote) child.ParentID = parent.ID if err := child.Store(ctx, tx); err != nil { t.Fatalf("child Store failed: %v", err) } // Retrieve parent and check children. gotParent := &Node{ID: parent.ID} if err := gotParent.Get(ctx, tx); err != nil { t.Fatalf("parent Get failed: %v", err) } if len(gotParent.Children) != 1 { t.Fatalf("expected 1 child, got %d", len(gotParent.Children)) } if gotParent.Children[0] != child.ID { t.Fatalf("child ID mismatch: got %q", gotParent.Children[0]) } // Retrieve child and check parent. gotChild := &Node{ID: child.ID} if err := gotChild.Get(ctx, tx); err != nil { t.Fatalf("child Get failed: %v", err) } if gotChild.ParentID != parent.ID { t.Fatalf("parent ID mismatch: got %q", gotChild.ParentID) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestNodeWithTags(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) // Create tags first (shared pool). if err := artifacts.CreateTag(ctx, tx, "philosophy"); err != nil { t.Fatalf("CreateTag failed: %v", err) } node := NewNode("PhilosophyNote", NodeTypeNote) node.Tags["philosophy"] = true if err := node.Store(ctx, tx); err != nil { t.Fatalf("Node.Store failed: %v", err) } got := &Node{ID: node.ID} if err := got.Get(ctx, tx); err != nil { t.Fatalf("Node.Get failed: %v", err) } if !got.Tags["philosophy"] { t.Fatalf("tag not found: %v", got.Tags) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestGetNodeByName(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) node := NewNode("FindMe", NodeTypeNote) if err := node.Store(ctx, tx); err != nil { t.Fatalf("Store failed: %v", err) } got, err := GetNodeByName(ctx, tx, "FindMe") if err != nil { t.Fatalf("GetNodeByName failed: %v", err) } if got == nil { t.Fatal("expected to find node") } if got.ID != node.ID { t.Fatalf("ID mismatch: got %q, want %q", got.ID, node.ID) } missing, err := GetNodeByName(ctx, tx, "NotHere") if err != nil { t.Fatalf("GetNodeByName failed: %v", err) } if missing != nil { t.Fatal("expected nil for missing node") } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } // --- Cell tests --- func TestCellStoreAndGet(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) node := NewNode("CellNote", NodeTypeNote) if err := node.Store(ctx, tx); err != nil { t.Fatalf("Node.Store failed: %v", err) } cell := NewCell(node.ID, CellTypeMarkdown, []byte("# Hello\nThis is a note.")) if err := cell.Store(ctx, tx); err != nil { t.Fatalf("Cell.Store failed: %v", err) } got := &Cell{ID: cell.ID} if err := got.Get(ctx, tx); err != nil { t.Fatalf("Cell.Get failed: %v", err) } if got.NodeID != node.ID { t.Fatalf("node ID mismatch: got %q", got.NodeID) } if got.Type != CellTypeMarkdown { t.Fatalf("type mismatch: got %q", got.Type) } if string(got.Contents) != "# Hello\nThis is a note." { t.Fatalf("contents mismatch: got %q", got.Contents) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestGetCellsForNode(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) node := NewNode("MultiCell", NodeTypeNote) if err := node.Store(ctx, tx); err != nil { t.Fatalf("Store failed: %v", err) } c1 := NewCell(node.ID, CellTypeMarkdown, []byte("First")) c1.Ordinal = 0 c2 := NewCell(node.ID, CellTypeCode, []byte("fmt.Println()")) c2.Ordinal = 1 c3 := NewCell(node.ID, CellTypeMarkdown, []byte("Last")) c3.Ordinal = 2 for _, c := range []*Cell{c1, c2, c3} { if err := c.Store(ctx, tx); err != nil { t.Fatalf("Cell.Store failed: %v", err) } } cells, err := GetCellsForNode(ctx, tx, node.ID) if err != nil { t.Fatalf("GetCellsForNode failed: %v", err) } if len(cells) != 3 { t.Fatalf("expected 3 cells, got %d", len(cells)) } if string(cells[0].Contents) != "First" { t.Fatalf("first cell content mismatch: %q", cells[0].Contents) } if cells[1].Type != CellTypeCode { t.Fatalf("second cell type mismatch: %q", cells[1].Type) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } // --- Fact tests --- func TestFactStoreAndGet(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) entityID := core.NewUUID() attrID := core.NewUUID() fact := NewFact(entityID, "TestEntity", attrID, "color", core.Vals("blue")) if err := fact.Store(ctx, tx); err != nil { t.Fatalf("Fact.Store failed: %v", err) } facts, err := GetFactsForEntity(ctx, tx, entityID) if err != nil { t.Fatalf("GetFactsForEntity failed: %v", err) } if len(facts) != 1 { t.Fatalf("expected 1 fact, got %d", len(facts)) } if facts[0].Value.Contents != "blue" { t.Fatalf("value mismatch: %q", facts[0].Value.Contents) } if facts[0].Retraction { t.Fatal("should not be a retraction") } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestFactRetraction(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) entityID := core.NewUUID() attrID := core.NewUUID() // Assert a fact. fact := NewFact(entityID, "Entity", attrID, "status", core.Vals("active")) if err := fact.Store(ctx, tx); err != nil { t.Fatalf("Store assertion failed: %v", err) } // Retract it. time.Sleep(10 * time.Millisecond) // ensure different timestamp retraction := fact.Retract() if err := retraction.Store(ctx, tx); err != nil { t.Fatalf("Store retraction failed: %v", err) } // All facts should include both. allFacts, err := GetFactsForEntity(ctx, tx, entityID) if err != nil { t.Fatalf("GetFactsForEntity failed: %v", err) } if len(allFacts) != 2 { t.Fatalf("expected 2 facts (assertion + retraction), got %d", len(allFacts)) } // Current facts should be empty (retracted). current, err := GetCurrentFactsForEntity(ctx, tx, entityID) if err != nil { t.Fatalf("GetCurrentFactsForEntity failed: %v", err) } if len(current) != 0 { t.Fatalf("expected 0 current facts after retraction, got %d", len(current)) } // Assert a new value. fact2 := NewFact(entityID, "Entity", attrID, "status", core.Vals("archived")) if err := fact2.Store(ctx, tx); err != nil { t.Fatalf("Store second assertion failed: %v", err) } current2, err := GetCurrentFactsForEntity(ctx, tx, entityID) if err != nil { t.Fatalf("GetCurrentFactsForEntity failed: %v", err) } if len(current2) != 1 { t.Fatalf("expected 1 current fact, got %d", len(current2)) } if current2[0].Value.Contents != "archived" { t.Fatalf("expected 'archived', got %q", current2[0].Value.Contents) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } // --- Edge tests --- func TestEdgeStoreAndGet(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) n1 := NewNode("Source", NodeTypeNote) n2 := NewNode("Target", NodeTypeNote) if err := n1.Store(ctx, tx); err != nil { t.Fatalf("Store n1 failed: %v", err) } if err := n2.Store(ctx, tx); err != nil { t.Fatalf("Store n2 failed: %v", err) } edge := NewEdge(n1.ID, n2.ID, EdgeRelationRelated) if err := edge.Store(ctx, tx); err != nil { t.Fatalf("Edge.Store failed: %v", err) } // Get edges from source. fromEdges, err := GetEdgesFrom(ctx, tx, n1.ID) if err != nil { t.Fatalf("GetEdgesFrom failed: %v", err) } if len(fromEdges) != 1 { t.Fatalf("expected 1 edge from source, got %d", len(fromEdges)) } if fromEdges[0].TargetID != n2.ID { t.Fatalf("target mismatch: got %q", fromEdges[0].TargetID) } if fromEdges[0].Relation != EdgeRelationRelated { t.Fatalf("relation mismatch: got %q", fromEdges[0].Relation) } // Get edges to target. toEdges, err := GetEdgesTo(ctx, tx, n2.ID) if err != nil { t.Fatalf("GetEdgesTo failed: %v", err) } if len(toEdges) != 1 { t.Fatalf("expected 1 edge to target, got %d", len(toEdges)) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } } func TestNodeToArtifactLink(t *testing.T) { database := mustOpenAndMigrate(t) tx, ctx := mustTX(t, database) node := NewNode("Research", NodeTypeNote) if err := node.Store(ctx, tx); err != nil { t.Fatalf("Store failed: %v", err) } // Link to an artifact ID (doesn't need to exist in edges table). artifactID := core.NewUUID() edge := NewEdge(node.ID, artifactID, EdgeRelationArtifactLink) if err := edge.Store(ctx, tx); err != nil { t.Fatalf("Edge.Store failed: %v", err) } edges, err := GetEdgesFrom(ctx, tx, node.ID) if err != nil { t.Fatalf("GetEdgesFrom failed: %v", err) } if len(edges) != 1 { t.Fatalf("expected 1 edge, got %d", len(edges)) } if edges[0].Relation != EdgeRelationArtifactLink { t.Fatalf("relation should be artifact_link, got %q", edges[0].Relation) } if err := db.EndTX(tx, nil); err != nil { t.Fatalf("EndTX failed: %v", err) } }