models_test.go
package models
import (
"testing"
"time"
)
// ---------- User CRUD ----------
func TestUserCRUD(t *testing.T) {
// Create
user := &User{
Email: "alice@example.com",
PasswordHash: "hashed_pw",
Name: "Alice",
Role: "admin",
Verified: true,
}
id, err := Users.Insert(user)
if err != nil {
t.Fatalf("Insert user: %v", err)
}
if id == "" {
t.Fatal("Insert should return a non-empty ID")
}
if user.ID != id {
t.Fatalf("user.ID = %q, want %q", user.ID, id)
}
// Get
got, err := Users.Get(id)
if err != nil {
t.Fatalf("Get user: %v", err)
}
if got.Email != "alice@example.com" {
t.Errorf("Email = %q, want %q", got.Email, "alice@example.com")
}
if got.Name != "Alice" {
t.Errorf("Name = %q, want %q", got.Name, "Alice")
}
if got.Role != "admin" {
t.Errorf("Role = %q, want %q", got.Role, "admin")
}
if !got.Verified {
t.Error("Verified should be true")
}
if got.CreatedAt.IsZero() {
t.Error("CreatedAt should be set")
}
// Update
got.Name = "Alice Updated"
got.Role = "user"
if err := Users.Update(got); err != nil {
t.Fatalf("Update user: %v", err)
}
got2, err := Users.Get(id)
if err != nil {
t.Fatalf("Get after update: %v", err)
}
if got2.Name != "Alice Updated" {
t.Errorf("Name after update = %q, want %q", got2.Name, "Alice Updated")
}
if got2.Role != "user" {
t.Errorf("Role after update = %q, want %q", got2.Role, "user")
}
// Delete
if err := Users.Delete(got2); err != nil {
t.Fatalf("Delete user: %v", err)
}
deleted, err := Users.Get(id)
if err == nil && deleted != nil {
t.Error("Get after delete should return error or nil")
}
}
// ---------- Page CRUD ----------
func TestPageCRUD(t *testing.T) {
// Create a page with a custom slug ID
page := &Page{
Position: 0,
}
page.ID = "about"
id, err := Pages.Insert(page)
if err != nil {
t.Fatalf("Insert page: %v", err)
}
if id != "about" {
t.Fatalf("ID = %q, want %q", id, "about")
}
// Create page content
content := &PageContent{
PageID: id,
Title: "About Us",
Description: "Learn more about us",
HTML: "<h1>About</h1><p>Hello world</p>",
Status: StatusPublished,
}
contentID, err := PageContents.Insert(content)
if err != nil {
t.Fatalf("Insert page content: %v", err)
}
if contentID == "" {
t.Fatal("PageContent insert should return a non-empty ID")
}
// Get page and verify content accessors
got, err := Pages.Get(id)
if err != nil {
t.Fatalf("Get page: %v", err)
}
if got.Title() != "About Us" {
t.Errorf("Title() = %q, want %q", got.Title(), "About Us")
}
if got.Description() != "Learn more about us" {
t.Errorf("Description() = %q, want %q", got.Description(), "Learn more about us")
}
if got.HTML() != "<h1>About</h1><p>Hello world</p>" {
t.Errorf("HTML() = %q, want expected value", got.HTML())
}
if got.Path() != "/about" {
t.Errorf("Path() = %q, want %q", got.Path(), "/about")
}
// IsPublished / Status
if !got.IsPublished() {
t.Error("Page with published content should report IsPublished() = true")
}
if got.Status() != StatusPublished {
t.Errorf("Status() = %q, want %q", got.Status(), StatusPublished)
}
// LatestContent / PublishedContent
latest := got.LatestContent()
if latest == nil {
t.Fatal("LatestContent() should not be nil")
}
if latest.Title != "About Us" {
t.Errorf("LatestContent().Title = %q, want %q", latest.Title, "About Us")
}
pub := got.PublishedContent()
if pub == nil {
t.Fatal("PublishedContent() should not be nil")
}
if !pub.IsPublished() {
t.Error("PublishedContent().IsPublished() should be true")
}
if pub.IsDraft() {
t.Error("PublishedContent().IsDraft() should be false")
}
// Add a draft version
draftContent := &PageContent{
PageID: id,
Title: "About Us - Draft",
Description: "Draft description",
HTML: "<h1>About Draft</h1>",
Status: StatusDraft,
}
_, err = PageContents.Insert(draftContent)
if err != nil {
t.Fatalf("Insert draft content: %v", err)
}
// Latest should now be the draft
latest2 := got.LatestContent()
if latest2 == nil {
t.Fatal("LatestContent() after draft insert should not be nil")
}
if latest2.Title != "About Us - Draft" {
t.Errorf("LatestContent().Title = %q, want %q", latest2.Title, "About Us - Draft")
}
if latest2.IsPublished() {
t.Error("Draft content should not be published")
}
if !latest2.IsDraft() {
t.Error("Draft content IsDraft() should be true")
}
// Contents should return multiple versions
contents, err := got.Contents()
if err != nil {
t.Fatalf("Contents(): %v", err)
}
if len(contents) != 2 {
t.Errorf("len(Contents()) = %d, want 2", len(contents))
}
// PageContent.Page() back-reference
parentPage, err := latest.Page()
if err != nil {
t.Fatalf("PageContent.Page(): %v", err)
}
if parentPage.ID != id {
t.Errorf("PageContent.Page().ID = %q, want %q", parentPage.ID, id)
}
// Update page
got.Position = 5
if err := Pages.Update(got); err != nil {
t.Fatalf("Update page: %v", err)
}
got2, err := Pages.Get(id)
if err != nil {
t.Fatalf("Get after update: %v", err)
}
if got2.Position != 5 {
t.Errorf("Position after update = %d, want 5", got2.Position)
}
// Delete page content, then page
for _, c := range contents {
if err := PageContents.Delete(c); err != nil {
t.Fatalf("Delete page content: %v", err)
}
}
if err := Pages.Delete(got2); err != nil {
t.Fatalf("Delete page: %v", err)
}
deleted, err := Pages.Get(id)
if err == nil && deleted != nil {
t.Error("Get after delete should return error or nil")
}
}
// ---------- Page Tree (Parent/Children) ----------
func TestPageTree(t *testing.T) {
// Create parent page
parent := &Page{Position: 0}
parent.ID = "services"
_, err := Pages.Insert(parent)
if err != nil {
t.Fatalf("Insert parent: %v", err)
}
// Create child pages
child1 := &Page{ParentID: "services", Position: 0}
child1.ID = "consulting"
_, err = Pages.Insert(child1)
if err != nil {
t.Fatalf("Insert child1: %v", err)
}
child2 := &Page{ParentID: "services", Position: 1}
child2.ID = "training"
_, err = Pages.Insert(child2)
if err != nil {
t.Fatalf("Insert child2: %v", err)
}
// Verify Children()
children, err := parent.Children()
if err != nil {
t.Fatalf("Children(): %v", err)
}
if len(children) != 2 {
t.Fatalf("len(Children()) = %d, want 2", len(children))
}
if children[0].ID != "consulting" {
t.Errorf("children[0].ID = %q, want %q", children[0].ID, "consulting")
}
if children[1].ID != "training" {
t.Errorf("children[1].ID = %q, want %q", children[1].ID, "training")
}
// Verify Parent()
p, err := child1.Parent()
if err != nil {
t.Fatalf("child1.Parent(): %v", err)
}
if p == nil {
t.Fatal("child1.Parent() should not be nil")
}
if p.ID != "services" {
t.Errorf("Parent().ID = %q, want %q", p.ID, "services")
}
// Root page has no parent
rootParent, err := parent.Parent()
if err != nil {
t.Fatalf("parent.Parent(): %v", err)
}
if rootParent != nil {
t.Error("Root page Parent() should be nil")
}
// Verify Siblings()
siblings, err := child1.Siblings()
if err != nil {
t.Fatalf("Siblings(): %v", err)
}
if len(siblings) != 1 {
t.Fatalf("len(Siblings()) = %d, want 1", len(siblings))
}
if siblings[0].ID != "training" {
t.Errorf("siblings[0].ID = %q, want %q", siblings[0].ID, "training")
}
// Verify Path() for nested pages
if child1.Path() != "/services/consulting" {
t.Errorf("child1.Path() = %q, want %q", child1.Path(), "/services/consulting")
}
if parent.Path() != "/services" {
t.Errorf("parent.Path() = %q, want %q", parent.Path(), "/services")
}
// Home page path
home := &Page{Position: 0}
home.ID = "home"
_, err = Pages.Insert(home)
if err != nil {
t.Fatalf("Insert home: %v", err)
}
if home.Path() != "/" {
t.Errorf("home.Path() = %q, want %q", home.Path(), "/")
}
// Cleanup
for _, p := range []*Page{child1, child2, parent, home} {
Pages.Delete(p)
}
}
// ---------- Collection CRUD ----------
func TestCollectionCRUD(t *testing.T) {
// Create
coll := &Collection{
Name: "Blog Posts",
Description: "All blog posts",
Type: CollectionTypeBase,
Schema: `[{"name":"title","type":"text","required":true}]`,
}
coll.ID = "blog_posts"
id, err := Collections.Insert(coll)
if err != nil {
t.Fatalf("Insert collection: %v", err)
}
if id != "blog_posts" {
t.Fatalf("ID = %q, want %q", id, "blog_posts")
}
// Get
got, err := Collections.Get(id)
if err != nil {
t.Fatalf("Get collection: %v", err)
}
if got.Name != "Blog Posts" {
t.Errorf("Name = %q, want %q", got.Name, "Blog Posts")
}
if got.Description != "All blog posts" {
t.Errorf("Description = %q, want %q", got.Description, "All blog posts")
}
if !got.IsBase() {
t.Error("IsBase() should be true for base collection")
}
if got.IsView() {
t.Error("IsView() should be false for base collection")
}
// Empty collection should have 0 documents
if got.DocumentCount() != 0 {
t.Errorf("DocumentCount() = %d, want 0", got.DocumentCount())
}
docs, err := got.Documents()
if err != nil {
t.Fatalf("Documents(): %v", err)
}
if len(docs) != 0 {
t.Errorf("len(Documents()) = %d, want 0", len(docs))
}
// Delete
if err := Collections.Delete(got); err != nil {
t.Fatalf("Delete collection: %v", err)
}
deleted, err := Collections.Get(id)
if err == nil && deleted != nil {
t.Error("Get after delete should return error or nil")
}
}
// ---------- Document CRUD ----------
func TestDocumentCRUD(t *testing.T) {
// Create parent collection
coll := &Collection{
Name: "Products",
Type: CollectionTypeBase,
Schema: `[{"name":"name","type":"text"},{"name":"price","type":"number"}]`,
}
coll.ID = "products"
_, err := Collections.Insert(coll)
if err != nil {
t.Fatalf("Insert collection: %v", err)
}
// Create document with JSON data
doc := &Document{
CollectionID: "products",
}
err = doc.SetAll(map[string]any{
"name": "Widget",
"price": 19.99,
})
if err != nil {
t.Fatalf("SetAll: %v", err)
}
id, err := Documents.Insert(doc)
if err != nil {
t.Fatalf("Insert document: %v", err)
}
if id == "" {
t.Fatal("Insert should return a non-empty ID")
}
// Get
got, err := Documents.Get(id)
if err != nil {
t.Fatalf("Get document: %v", err)
}
if got.CollectionID != "products" {
t.Errorf("CollectionID = %q, want %q", got.CollectionID, "products")
}
// Back-reference to collection
parent, err := got.Collection()
if err != nil {
t.Fatalf("Document.Collection(): %v", err)
}
if parent.ID != "products" {
t.Errorf("Collection().ID = %q, want %q", parent.ID, "products")
}
// Collection.DocumentCount should now be 1
if coll.DocumentCount() != 1 {
t.Errorf("DocumentCount() = %d, want 1", coll.DocumentCount())
}
// Update document data using Set
if err := got.Set("name", "Super Widget"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := Documents.Update(got); err != nil {
t.Fatalf("Update document: %v", err)
}
got2, err := Documents.Get(id)
if err != nil {
t.Fatalf("Get after update: %v", err)
}
if got2.GetString("name") != "Super Widget" {
t.Errorf("GetString(name) = %q, want %q", got2.GetString("name"), "Super Widget")
}
// Delete document
if err := Documents.Delete(got2); err != nil {
t.Fatalf("Delete document: %v", err)
}
deleted, err := Documents.Get(id)
if err == nil && deleted != nil {
t.Error("Get after delete should return error or nil")
}
// Cleanup collection
Collections.Delete(coll)
}
// ---------- Document Data Access ----------
func TestDocumentDataAccess(t *testing.T) {
doc := &Document{}
err := doc.SetAll(map[string]any{
"title": "My Post",
"count": 42.0, // JSON numbers are float64
"published": true,
"created": "2025-01-15T10:30:00Z",
"tags": []any{"go", "web", "cms"},
"rating": 4.5,
})
if err != nil {
t.Fatalf("SetAll: %v", err)
}
// GetString
if got := doc.GetString("title"); got != "My Post" {
t.Errorf("GetString(title) = %q, want %q", got, "My Post")
}
if got := doc.GetString("nonexistent"); got != "" {
t.Errorf("GetString(nonexistent) = %q, want empty", got)
}
// GetInt
if got := doc.GetInt("count"); got != 42 {
t.Errorf("GetInt(count) = %d, want 42", got)
}
if got := doc.GetInt("nonexistent"); got != 0 {
t.Errorf("GetInt(nonexistent) = %d, want 0", got)
}
// GetFloat
if got := doc.GetFloat("rating"); got != 4.5 {
t.Errorf("GetFloat(rating) = %f, want 4.5", got)
}
// GetBool
if got := doc.GetBool("published"); !got {
t.Error("GetBool(published) = false, want true")
}
if got := doc.GetBool("nonexistent"); got {
t.Error("GetBool(nonexistent) = true, want false")
}
// GetTime
expectedTime, _ := time.Parse(time.RFC3339, "2025-01-15T10:30:00Z")
if got := doc.GetTime("created"); !got.Equal(expectedTime) {
t.Errorf("GetTime(created) = %v, want %v", got, expectedTime)
}
if got := doc.GetTime("nonexistent"); !got.IsZero() {
t.Errorf("GetTime(nonexistent) = %v, want zero", got)
}
// GetStrings
tags := doc.GetStrings("tags")
if len(tags) != 3 {
t.Fatalf("len(GetStrings(tags)) = %d, want 3", len(tags))
}
if tags[0] != "go" || tags[1] != "web" || tags[2] != "cms" {
t.Errorf("GetStrings(tags) = %v, want [go web cms]", tags)
}
if got := doc.GetStrings("nonexistent"); got != nil {
t.Errorf("GetStrings(nonexistent) = %v, want nil", got)
}
// Set single field
if err := doc.Set("title", "Updated Post"); err != nil {
t.Fatalf("Set: %v", err)
}
if got := doc.GetString("title"); got != "Updated Post" {
t.Errorf("GetString after Set = %q, want %q", got, "Updated Post")
}
// Verify other fields are preserved
if got := doc.GetInt("count"); got != 42 {
t.Errorf("GetInt(count) after Set(title) = %d, want 42 (should be preserved)", got)
}
// Nil document returns zero values
var nilDoc *Document
if got := nilDoc.GetString("any"); got != "" {
t.Errorf("nil.GetString = %q, want empty", got)
}
if got := nilDoc.GetInt("any"); got != 0 {
t.Errorf("nil.GetInt = %d, want 0", got)
}
if got := nilDoc.GetBool("any"); got {
t.Error("nil.GetBool = true, want false")
}
if got := nilDoc.GetTime("any"); !got.IsZero() {
t.Errorf("nil.GetTime = %v, want zero", got)
}
if got := nilDoc.GetFloat("any"); got != 0 {
t.Errorf("nil.GetFloat = %f, want 0", got)
}
if got := nilDoc.GetStrings("any"); got != nil {
t.Errorf("nil.GetStrings = %v, want nil", got)
}
}
// ---------- Conversation / Messages ----------
func TestConversationMessages(t *testing.T) {
// Create a user for the conversation
user := &User{
Email: "bob@example.com",
Name: "Bob",
Role: "user",
}
userID, err := Users.Insert(user)
if err != nil {
t.Fatalf("Insert user: %v", err)
}
// Create conversation
conv := &Conversation{
UserID: userID,
Title: "Help with my site",
Context: `{"currentPageID":"home"}`,
}
convID, err := Conversations.Insert(conv)
if err != nil {
t.Fatalf("Insert conversation: %v", err)
}
if convID == "" {
t.Fatal("Conversation ID should not be empty")
}
// Verify conversation.User() back-reference
convUser, err := conv.User()
if err != nil {
t.Fatalf("Conversation.User(): %v", err)
}
if convUser.ID != userID {
t.Errorf("User().ID = %q, want %q", convUser.ID, userID)
}
// User.Conversations() should list this conversation
convs, err := user.Conversations()
if err != nil {
t.Fatalf("User.Conversations(): %v", err)
}
if len(convs) != 1 {
t.Fatalf("len(Conversations()) = %d, want 1", len(convs))
}
if convs[0].ID != convID {
t.Errorf("Conversations()[0].ID = %q, want %q", convs[0].ID, convID)
}
// Empty conversation should have 0 messages
if conv.MessageCount() != 0 {
t.Errorf("MessageCount() = %d, want 0", conv.MessageCount())
}
// Add messages
msg1 := &Message{
ConversationID: convID,
Role: "user",
Content: "Can you create an about page?",
Status: "complete",
}
msg1ID, err := Messages.Insert(msg1)
if err != nil {
t.Fatalf("Insert message 1: %v", err)
}
msg2 := &Message{
ConversationID: convID,
Role: "assistant",
Content: "Sure, I will create an about page for you.",
ToolCalls: `[{"id":"call_1","name":"create_page","arguments":"{}","result":"done","error":""}]`,
Status: "complete",
}
msg2ID, err := Messages.Insert(msg2)
if err != nil {
t.Fatalf("Insert message 2: %v", err)
}
// Verify MessageCount
if conv.MessageCount() != 2 {
t.Errorf("MessageCount() = %d, want 2", conv.MessageCount())
}
// Verify Messages()
msgs, err := conv.Messages()
if err != nil {
t.Fatalf("Messages(): %v", err)
}
if len(msgs) != 2 {
t.Fatalf("len(Messages()) = %d, want 2", len(msgs))
}
if msgs[0].ID != msg1ID {
t.Errorf("msgs[0].ID = %q, want %q", msgs[0].ID, msg1ID)
}
if msgs[1].ID != msg2ID {
t.Errorf("msgs[1].ID = %q, want %q", msgs[1].ID, msg2ID)
}
// Verify LastMessage()
last, err := conv.LastMessage()
if err != nil {
t.Fatalf("LastMessage(): %v", err)
}
if last.ID != msg2ID {
t.Errorf("LastMessage().ID = %q, want %q", last.ID, msg2ID)
}
// Verify Message.Conversation() back-reference
msgConv, err := msg1.Conversation()
if err != nil {
t.Fatalf("Message.Conversation(): %v", err)
}
if msgConv.ID != convID {
t.Errorf("Conversation().ID = %q, want %q", msgConv.ID, convID)
}
// Verify ToolCalls on msg2
if !msg2.HasToolCalls() {
t.Error("msg2 should have tool calls")
}
if msg2.ToolCallCount() != 1 {
t.Errorf("ToolCallCount() = %d, want 1", msg2.ToolCallCount())
}
tcList := msg2.ToolCallsList()
if len(tcList) != 1 {
t.Fatalf("len(ToolCallsList()) = %d, want 1", len(tcList))
}
if tcList[0].Name != "create_page" {
t.Errorf("ToolCallsList()[0].Name = %q, want %q", tcList[0].Name, "create_page")
}
// Message without tool calls
if msg1.HasToolCalls() {
t.Error("msg1 should not have tool calls")
}
if msg1.ToolCallCount() != 0 {
t.Errorf("msg1.ToolCallCount() = %d, want 0", msg1.ToolCallCount())
}
// Verify status helpers
pendingMsg := &Message{Status: "pending"}
if !pendingMsg.IsPending() {
t.Error("IsPending() should be true for pending message")
}
streamingMsg := &Message{Status: "streaming"}
if !streamingMsg.IsStreaming() {
t.Error("IsStreaming() should be true for streaming message")
}
// Cleanup
Messages.Delete(msg1)
Messages.Delete(msg2)
Conversations.Delete(conv)
Users.Delete(user)
}
// ---------- Mutation Tracking ----------
func TestMutationTracking(t *testing.T) {
// Create conversation and message for the mutation
conv := &Conversation{
UserID: "test-user-mut",
Title: "Mutation test",
}
convID, err := Conversations.Insert(conv)
if err != nil {
t.Fatalf("Insert conversation: %v", err)
}
msg := &Message{
ConversationID: convID,
Role: "assistant",
Content: "Creating your about page now.",
Status: "complete",
}
msgID, err := Messages.Insert(msg)
if err != nil {
t.Fatalf("Insert message: %v", err)
}
// Create a mutation
mut := &Mutation{
MessageID: msgID,
Action: "create",
EntityType: "page",
EntityID: "about",
BeforeState: "",
AfterState: `{"id":"about","title":"About Us"}`,
Undone: false,
}
mutID, err := Mutations.Insert(mut)
if err != nil {
t.Fatalf("Insert mutation: %v", err)
}
if mutID == "" {
t.Fatal("Mutation ID should not be empty")
}
// Get mutation
got, err := Mutations.Get(mutID)
if err != nil {
t.Fatalf("Get mutation: %v", err)
}
if got.MessageID != msgID {
t.Errorf("MessageID = %q, want %q", got.MessageID, msgID)
}
if got.Action != "create" {
t.Errorf("Action = %q, want %q", got.Action, "create")
}
if got.EntityType != "page" {
t.Errorf("EntityType = %q, want %q", got.EntityType, "page")
}
if got.EntityID != "about" {
t.Errorf("EntityID = %q, want %q", got.EntityID, "about")
}
if got.Undone {
t.Error("Undone should be false initially")
}
if got.AfterState != `{"id":"about","title":"About Us"}` {
t.Errorf("AfterState = %q, unexpected", got.AfterState)
}
// Verify Mutation.Message() back-reference
mutMsg, err := got.Message()
if err != nil {
t.Fatalf("Mutation.Message(): %v", err)
}
if mutMsg.ID != msgID {
t.Errorf("Message().ID = %q, want %q", mutMsg.ID, msgID)
}
// Verify Message.Mutations()
mutations, err := msg.Mutations()
if err != nil {
t.Fatalf("Message.Mutations(): %v", err)
}
if len(mutations) != 1 {
t.Fatalf("len(Mutations()) = %d, want 1", len(mutations))
}
if mutations[0].ID != mutID {
t.Errorf("Mutations()[0].ID = %q, want %q", mutations[0].ID, mutID)
}
// Add a second mutation (update) to the same message
mut2 := &Mutation{
MessageID: msgID,
Action: "update",
EntityType: "page",
EntityID: "about",
BeforeState: `{"id":"about","title":"About Us"}`,
AfterState: `{"id":"about","title":"About Our Team"}`,
Undone: false,
}
mut2ID, err := Mutations.Insert(mut2)
if err != nil {
t.Fatalf("Insert mutation 2: %v", err)
}
mutations2, err := msg.Mutations()
if err != nil {
t.Fatalf("Message.Mutations() after second insert: %v", err)
}
if len(mutations2) != 2 {
t.Fatalf("len(Mutations()) = %d, want 2", len(mutations2))
}
// Mark mutation as undone
got.Undone = true
if err := Mutations.Update(got); err != nil {
t.Fatalf("Update mutation: %v", err)
}
updated, err := Mutations.Get(mutID)
if err != nil {
t.Fatalf("Get after update: %v", err)
}
if !updated.Undone {
t.Error("Undone should be true after update")
}
// Cleanup
Mutations.Delete(mut)
Mutations.Delete(mut2)
_ = mut2ID // suppress unused
Messages.Delete(msg)
Conversations.Delete(conv)
}