From 96857e81106e7d8d098586ceb930d13f377d9ddb Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Fri, 8 Aug 2025 23:52:23 -0500 Subject: [PATCH] create forum package --- internal/forum/builder.go | 131 +++++++ internal/forum/doc.go | 627 +++++++++++++++++++++++++++++++++ internal/forum/forum.go | 409 +++++++++++++++++++++ internal/forum/forum_test.go | 665 +++++++++++++++++++++++++++++++++++ 4 files changed, 1832 insertions(+) create mode 100644 internal/forum/builder.go create mode 100644 internal/forum/doc.go create mode 100644 internal/forum/forum.go create mode 100644 internal/forum/forum_test.go diff --git a/internal/forum/builder.go b/internal/forum/builder.go new file mode 100644 index 0000000..fb1d794 --- /dev/null +++ b/internal/forum/builder.go @@ -0,0 +1,131 @@ +package forum + +import ( + "fmt" + "time" + + "dk/internal/database" + + "zombiezen.com/go/sqlite" +) + +// Builder provides a fluent interface for creating forum posts +type Builder struct { + forum *Forum + db *database.DB +} + +// NewBuilder creates a new forum post builder +func NewBuilder(db *database.DB) *Builder { + now := time.Now().Unix() + return &Builder{ + forum: &Forum{ + db: db, + Posted: now, + LastPost: now, // Default to same as posted time + Parent: 0, // Default to thread (no parent) + Replies: 0, // Default to no replies + }, + db: db, + } +} + +// WithAuthor sets the author user ID +func (b *Builder) WithAuthor(authorID int) *Builder { + b.forum.Author = authorID + return b +} + +// WithTitle sets the forum post title +func (b *Builder) WithTitle(title string) *Builder { + b.forum.Title = title + return b +} + +// WithContent sets the forum post content +func (b *Builder) WithContent(content string) *Builder { + b.forum.Content = content + return b +} + +// WithParent sets the parent post ID (for replies) +func (b *Builder) WithParent(parentID int) *Builder { + b.forum.Parent = parentID + return b +} + +// AsReply is a convenience method to set this as a reply to another post +func (b *Builder) AsReply(parentID int) *Builder { + return b.WithParent(parentID) +} + +// AsThread ensures this is a top-level thread (parent = 0) +func (b *Builder) AsThread() *Builder { + b.forum.Parent = 0 + return b +} + +// WithPosted sets the posted timestamp +func (b *Builder) WithPosted(posted int64) *Builder { + b.forum.Posted = posted + return b +} + +// WithPostedTime sets the posted timestamp from a time.Time +func (b *Builder) WithPostedTime(t time.Time) *Builder { + b.forum.Posted = t.Unix() + return b +} + +// WithLastPost sets the last post timestamp +func (b *Builder) WithLastPost(lastPost int64) *Builder { + b.forum.LastPost = lastPost + return b +} + +// WithLastPostTime sets the last post timestamp from a time.Time +func (b *Builder) WithLastPostTime(t time.Time) *Builder { + b.forum.LastPost = t.Unix() + return b +} + +// WithReplies sets the initial reply count +func (b *Builder) WithReplies(replies int) *Builder { + b.forum.Replies = replies + return b +} + +// Create saves the forum post to the database and returns the created post with ID +func (b *Builder) Create() (*Forum, error) { + // Use a transaction to ensure we can get the ID + var forum *Forum + err := b.db.Transaction(func(tx *database.Tx) error { + query := `INSERT INTO forum (posted, last_post, author, parent, replies, title, content) + VALUES (?, ?, ?, ?, ?, ?, ?)` + + if err := tx.Exec(query, b.forum.Posted, b.forum.LastPost, b.forum.Author, + b.forum.Parent, b.forum.Replies, b.forum.Title, b.forum.Content); err != nil { + return fmt.Errorf("failed to insert forum post: %w", err) + } + + // Get the last insert ID + var id int + err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error { + id = stmt.ColumnInt(0) + return nil + }) + if err != nil { + return fmt.Errorf("failed to get insert ID: %w", err) + } + + b.forum.ID = id + forum = b.forum + return nil + }) + + if err != nil { + return nil, err + } + + return forum, nil +} \ No newline at end of file diff --git a/internal/forum/doc.go b/internal/forum/doc.go new file mode 100644 index 0000000..0eb97ae --- /dev/null +++ b/internal/forum/doc.go @@ -0,0 +1,627 @@ +/* +Package forum is the active record implementation for forum posts and threads in the game. + +The forum package provides a complete forum system with thread and reply functionality, search capabilities, and comprehensive post management. It supports hierarchical discussions with parent/child relationships between posts. + +# Basic Usage + +To retrieve a forum post by ID: + + post, err := forum.Find(db, 1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("[%s] %s by user %d\n", + post.PostedTime().Format("Jan 2"), post.Title, post.Author) + +To get all forum threads (top-level posts): + + threads, err := forum.Threads(db) + if err != nil { + log.Fatal(err) + } + for _, thread := range threads { + fmt.Printf("Thread: %s (%d replies)\n", thread.Title, thread.Replies) + } + +To get replies to a specific thread: + + replies, err := forum.ByParent(db, threadID) + if err != nil { + log.Fatal(err) + } + +To search forum posts: + + results, err := forum.Search(db, "strategy") + if err != nil { + log.Fatal(err) + } + +# Creating Posts with Builder Pattern + +The package provides a fluent builder interface for creating new forum posts: + +## Creating a New Thread + + thread, err := forum.NewBuilder(db). + WithAuthor(userID). + WithTitle("New Strategy Discussion"). + WithContent("What are your thoughts on the best character builds?"). + AsThread(). + Create() + + if err != nil { + log.Fatal(err) + } + fmt.Printf("Created thread with ID: %d\n", thread.ID) + +## Creating a Reply + + reply, err := forum.NewBuilder(db). + WithAuthor(userID). + WithTitle("Re: Strategy Discussion"). + WithContent("I think mage builds are overpowered right now."). + AsReply(parentThreadID). + Create() + + if err != nil { + log.Fatal(err) + } + +The builder automatically sets timestamps to the current time if not specified. + +# Updating Posts + +Forum posts can be modified and saved back to the database: + + post, _ := forum.Find(db, 1) + post.Title = "[UPDATED] " + post.Title + post.Content = post.Content + "\n\nEDIT: Added clarification." + post.UpdateLastPost() // Update last activity timestamp + + err := post.Save() + if err != nil { + log.Fatal(err) + } + +# Deleting Posts + +Posts can be removed from the database: + + post, _ := forum.Find(db, 1) + err := post.Delete() + if err != nil { + log.Fatal(err) + } + +# Database Schema + +The forum table has the following structure: + + CREATE TABLE forum ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + posted INTEGER NOT NULL DEFAULT (unixepoch()), + last_post INTEGER NOT NULL DEFAULT (unixepoch()), + author INTEGER NOT NULL, + parent INTEGER NOT NULL DEFAULT 0, + replies INTEGER NOT NULL DEFAULT 0, + title TEXT NOT NULL, + content TEXT NOT NULL + ) + +Where: + - id: Unique identifier for the forum post + - posted: Unix timestamp when the post was originally created + - last_post: Unix timestamp of the most recent activity on this thread/post + - author: User ID of the post author + - parent: Parent post ID (0 for top-level threads, >0 for replies) + - replies: Number of direct replies to this post + - title: Post title/subject line + - content: Post content/body text + +# Thread and Reply System + +## Thread Structure + +The forum uses a parent/child hierarchy: + + // Top-level threads have parent = 0 + if post.IsThread() { + fmt.Println("This is a main thread") + } + + // Replies have parent > 0 + if post.IsReply() { + fmt.Printf("This is a reply to post %d\n", post.Parent) + } + +## Working with Threads + +Get all top-level threads: + + threads, _ := forum.Threads(db) + + fmt.Println("=== Forum Threads ===") + for _, thread := range threads { + age := thread.ActivityAge() + var activityStr string + + if age < time.Hour { + activityStr = fmt.Sprintf("%d minutes ago", int(age.Minutes())) + } else if age < 24*time.Hour { + activityStr = fmt.Sprintf("%d hours ago", int(age.Hours())) + } else { + activityStr = fmt.Sprintf("%d days ago", int(age.Hours()/24)) + } + + fmt.Printf("📌 %s (%d replies, last activity %s)\n", + thread.Title, thread.Replies, activityStr) + } + +## Working with Replies + +Get all replies to a thread: + + thread, _ := forum.Find(db, threadID) + replies, _ := thread.GetReplies() + + fmt.Printf("=== %s ===\n", thread.Title) + fmt.Printf("Posted by user %d on %s\n\n", + thread.Author, thread.PostedTime().Format("Jan 2, 2006")) + fmt.Println(thread.Content) + + if len(replies) > 0 { + fmt.Printf("\n--- %d Replies ---\n", len(replies)) + for i, reply := range replies { + fmt.Printf("[%d] by user %d on %s:\n%s\n\n", + i+1, reply.Author, reply.PostedTime().Format("Jan 2 15:04"), + reply.Content) + } + } + +## Navigation Between Posts + +Navigate the thread hierarchy: + + reply, _ := forum.Find(db, replyID) + + // Get the parent thread + thread, err := reply.GetThread() + if err == nil { + fmt.Printf("This reply belongs to thread: %s\n", thread.Title) + } + + // Get all sibling replies + siblings, _ := forum.ByParent(db, reply.Parent) + fmt.Printf("This thread has %d total replies\n", len(siblings)) + +# Search and Filtering + +## Text Search + +Search within titles and content: + + // Search for posts about "pvp" + pvpPosts, err := forum.Search(db, "pvp") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d posts about PvP:\n", len(pvpPosts)) + for _, post := range pvpPosts { + fmt.Printf("- %s: %s\n", post.Title, post.Preview(60)) + } + +Search is case-insensitive and searches both titles and content. + +## Author-Based Queries + +Find posts by specific users: + + // Get all posts by a user + userPosts, err := forum.ByAuthor(db, userID) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("User %d has made %d posts:\n", userID, len(userPosts)) + for _, post := range userPosts { + postType := "thread" + if post.IsReply() { + postType = "reply" + } + fmt.Printf("- [%s] %s (%s ago)\n", + postType, post.Title, post.PostAge()) + } + +## Activity-Based Queries + +Find recent activity: + + // Get recent forum activity + recentPosts, err := forum.Recent(db, 20) + if err != nil { + log.Fatal(err) + } + + // Get activity since user's last visit + lastVisit := getUserLastVisit(userID) + newActivity, err := forum.Since(db, lastVisit) + if len(newActivity) > 0 { + fmt.Printf("There have been %d new posts since your last visit\n", + len(newActivity)) + } + +# Post Management + +## Reply Count Management + +Manage reply counts when posts are created or deleted: + + // When creating a reply + reply, err := forum.NewBuilder(db). + WithAuthor(userID). + WithContent("Great point!"). + AsReply(parentID). + Create() + + if err == nil { + // Update parent thread's reply count and last activity + parentThread, _ := forum.Find(db, parentID) + parentThread.IncrementReplies() + parentThread.UpdateLastPost() + parentThread.Save() + } + + // When deleting a reply + replyToDelete, _ := forum.Find(db, replyID) + parentID := replyToDelete.Parent + + replyToDelete.Delete() + + // Update parent thread + if parentID > 0 { + parentThread, _ := forum.Find(db, parentID) + parentThread.DecrementReplies() + parentThread.Save() + } + +## Content Analysis + +Analyze post content: + + post, _ := forum.Find(db, postID) + + // Basic content metrics + fmt.Printf("Post length: %d characters\n", post.Length()) + fmt.Printf("Word count: %d words\n", post.WordCount()) + + // Check for specific terms + if post.Contains("bug") { + fmt.Println("This post mentions a bug") + } + + // Generate preview for listings + preview := post.Preview(100) + fmt.Printf("Preview: %s\n", preview) + +## Time Analysis + +Track posting and activity patterns: + + post, _ := forum.Find(db, postID) + + postAge := post.PostAge() + activityAge := post.ActivityAge() + + fmt.Printf("Post created %v ago\n", postAge) + fmt.Printf("Last activity %v ago\n", activityAge) + + if post.IsRecentActivity() { + fmt.Println("This thread has recent activity") + } + +# Forum Display Patterns + +## Thread Listing + +Display forum index: + + func displayForumIndex(db *database.DB) { + threads, _ := forum.Threads(db) + + fmt.Println("=== Game Forum ===") + fmt.Printf("%-40s %-8s %-15s\n", "Thread", "Replies", "Last Activity") + fmt.Println(strings.Repeat("-", 65)) + + for _, thread := range threads { + title := thread.Title + if len(title) > 37 { + title = title[:37] + "..." + } + + age := thread.ActivityAge() + var ageStr string + if age < time.Hour { + ageStr = fmt.Sprintf("%dm ago", int(age.Minutes())) + } else if age < 24*time.Hour { + ageStr = fmt.Sprintf("%dh ago", int(age.Hours())) + } else { + ageStr = fmt.Sprintf("%dd ago", int(age.Hours()/24)) + } + + fmt.Printf("%-40s %-8d %-15s\n", title, thread.Replies, ageStr) + } + } + +## Thread View + +Display a complete thread with replies: + + func displayThread(db *database.DB, threadID int) error { + thread, err := forum.Find(db, threadID) + if err != nil { + return err + } + + if !thread.IsThread() { + return fmt.Errorf("post %d is not a thread", threadID) + } + + // Display thread + fmt.Printf("=== %s ===\n", thread.Title) + fmt.Printf("By user %d on %s\n\n", + thread.Author, thread.PostedTime().Format("January 2, 2006 at 3:04 PM")) + fmt.Println(thread.Content) + + // Display replies + replies, _ := thread.GetReplies() + if len(replies) > 0 { + fmt.Printf("\n--- %d Replies ---\n\n", len(replies)) + + for i, reply := range replies { + fmt.Printf("#%d by user %d on %s:\n", + i+1, reply.Author, reply.PostedTime().Format("Jan 2 at 3:04 PM")) + fmt.Println(reply.Content) + fmt.Println() + } + } + + return nil + } + +# Moderation Features + +## Content Moderation + +Tools for forum moderation: + + // Flag posts for review + func moderatePost(db *database.DB, postID int) { + post, err := forum.Find(db, postID) + if err != nil { + return + } + + // Check for very short posts (potential spam) + if post.WordCount() < 3 { + fmt.Printf("Short post flagged: %s\n", post.Preview(30)) + } + + // Check for very long posts + if post.Length() > 5000 { + fmt.Printf("Very long post from user %d\n", post.Author) + } + + // Check for specific terms + suspiciousTerms := []string{"spam", "hack", "cheat"} + for _, term := range suspiciousTerms { + if post.Contains(term) { + fmt.Printf("Post contains suspicious term '%s'\n", term) + } + } + } + +## User Activity Analysis + +Analyze user forum behavior: + + func analyzeUserActivity(db *database.DB, userID int) { + posts, _ := forum.ByAuthor(db, userID) + + fmt.Printf("User %d forum activity:\n", userID) + fmt.Printf("- Total posts: %d\n", len(posts)) + + threadCount := 0 + replyCount := 0 + totalWords := 0 + + for _, post := range posts { + if post.IsThread() { + threadCount++ + } else { + replyCount++ + } + totalWords += post.WordCount() + } + + fmt.Printf("- Threads started: %d\n", threadCount) + fmt.Printf("- Replies posted: %d\n", replyCount) + + if len(posts) > 0 { + avgWords := totalWords / len(posts) + fmt.Printf("- Average words per post: %d\n", avgWords) + + latest := posts[0] // ByAuthor returns newest first + fmt.Printf("- Last post: %s (%s ago)\n", + latest.Title, latest.PostAge()) + } + } + +# Performance Considerations + +## Efficient Queries + +Optimize database queries for forum performance: + + // Use specific queries for common operations + threads, _ := forum.Threads(db) // More efficient than filtering All() + + // Limit results for pagination + recentPosts, _ := forum.Recent(db, 25) // Get page worth of data + + // Use specific parent queries + replies, _ := forum.ByParent(db, threadID) // Efficient for thread display + +## Caching Strategies + +Cache frequently accessed data: + + // Cache popular threads + var popularThreadsCache []*forum.Forum + var cacheTime time.Time + + func getPopularThreads(db *database.DB) []*forum.Forum { + // Refresh cache every 5 minutes + if time.Since(cacheTime) > 5*time.Minute { + threads, _ := forum.Threads(db) + + // Sort by activity (replies + recent posts) + sort.Slice(threads, func(i, j int) bool { + scoreI := threads[i].Replies + scoreJ := threads[j].Replies + + // Bonus for recent activity + if threads[i].IsRecentActivity() { + scoreI += 10 + } + if threads[j].IsRecentActivity() { + scoreJ += 10 + } + + return scoreI > scoreJ + }) + + // Cache top 10 + if len(threads) > 10 { + threads = threads[:10] + } + + popularThreadsCache = threads + cacheTime = time.Now() + } + + return popularThreadsCache + } + +# Integration Examples + +## User Notifications + +Integrate with notification system: + + func notifyNewReply(db *database.DB, replyID int) { + reply, err := forum.Find(db, replyID) + if err != nil { + return + } + + // Get the parent thread + thread, err := reply.GetThread() + if err != nil { + return + } + + // Notify thread author if different from reply author + if thread.Author != reply.Author { + message := fmt.Sprintf("New reply to your thread '%s'", thread.Title) + sendNotification(thread.Author, message) + } + + // Notify other participants in the thread + allReplies, _ := thread.GetReplies() + participants := make(map[int]bool) + participants[thread.Author] = true + + for _, r := range allReplies { + if r.Author != reply.Author && !participants[r.Author] { + message := fmt.Sprintf("New activity in thread '%s'", thread.Title) + sendNotification(r.Author, message) + participants[r.Author] = true + } + } + } + +## Search Integration + +Provide advanced search capabilities: + + func advancedSearch(db *database.DB, query string, authorID int, + onlyThreads bool, since time.Time) []*forum.Forum { + + var results []*forum.Forum + + // Start with text search + if query != "" { + textResults, _ := forum.Search(db, query) + results = append(results, textResults...) + } else { + allPosts, _ := forum.All(db) + results = allPosts + } + + // Apply filters + var filtered []*forum.Forum + sinceUnix := since.Unix() + + for _, post := range results { + // Author filter + if authorID > 0 && post.Author != authorID { + continue + } + + // Thread-only filter + if onlyThreads && !post.IsThread() { + continue + } + + // Time filter + if post.LastPost < sinceUnix { + continue + } + + filtered = append(filtered, post) + } + + return filtered + } + +# Error Handling + +Common error scenarios and handling: + + post, err := forum.Find(db, postID) + if err != nil { + // Handle post not found or database issues + log.Printf("Failed to find post %d: %v", postID, err) + return + } + + // Validate post relationships + if post.IsReply() { + parentThread, err := post.GetThread() + if err != nil { + log.Printf("Warning: Reply %d has invalid parent %d", + post.ID, post.Parent) + } + } + + // Save with error handling + if err := post.Save(); err != nil { + log.Printf("Failed to save post %d: %v", post.ID, err) + } + +The forum package provides a complete forum system with hierarchical discussions, search capabilities, and comprehensive post management suitable for game communities. +*/ +package forum \ No newline at end of file diff --git a/internal/forum/forum.go b/internal/forum/forum.go new file mode 100644 index 0000000..4bd9ef9 --- /dev/null +++ b/internal/forum/forum.go @@ -0,0 +1,409 @@ +package forum + +import ( + "fmt" + "strings" + "time" + + "dk/internal/database" + + "zombiezen.com/go/sqlite" +) + +// Forum represents a forum post or thread in the database +type Forum struct { + ID int `json:"id"` + Posted int64 `json:"posted"` + LastPost int64 `json:"last_post"` + Author int `json:"author"` + Parent int `json:"parent"` + Replies int `json:"replies"` + Title string `json:"title"` + Content string `json:"content"` + + db *database.DB +} + +// Find retrieves a forum post by ID +func Find(db *database.DB, id int) (*Forum, error) { + forum := &Forum{db: db} + + query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE id = ?" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + forum.ID = stmt.ColumnInt(0) + forum.Posted = stmt.ColumnInt64(1) + forum.LastPost = stmt.ColumnInt64(2) + forum.Author = stmt.ColumnInt(3) + forum.Parent = stmt.ColumnInt(4) + forum.Replies = stmt.ColumnInt(5) + forum.Title = stmt.ColumnText(6) + forum.Content = stmt.ColumnText(7) + return nil + }, id) + + if err != nil { + return nil, fmt.Errorf("failed to find forum post: %w", err) + } + + if forum.ID == 0 { + return nil, fmt.Errorf("forum post with ID %d not found", id) + } + + return forum, nil +} + +// All retrieves all forum posts ordered by last post time (most recent first) +func All(db *database.DB) ([]*Forum, error) { + var forums []*Forum + + query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum ORDER BY last_post DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + forum := &Forum{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + LastPost: stmt.ColumnInt64(2), + Author: stmt.ColumnInt(3), + Parent: stmt.ColumnInt(4), + Replies: stmt.ColumnInt(5), + Title: stmt.ColumnText(6), + Content: stmt.ColumnText(7), + db: db, + } + forums = append(forums, forum) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve all forum posts: %w", err) + } + + return forums, nil +} + +// Threads retrieves all top-level forum threads (parent = 0) +func Threads(db *database.DB) ([]*Forum, error) { + var forums []*Forum + + query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE parent = 0 ORDER BY last_post DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + forum := &Forum{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + LastPost: stmt.ColumnInt64(2), + Author: stmt.ColumnInt(3), + Parent: stmt.ColumnInt(4), + Replies: stmt.ColumnInt(5), + Title: stmt.ColumnText(6), + Content: stmt.ColumnText(7), + db: db, + } + forums = append(forums, forum) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve forum threads: %w", err) + } + + return forums, nil +} + +// ByParent retrieves all replies to a specific thread/post +func ByParent(db *database.DB, parentID int) ([]*Forum, error) { + var forums []*Forum + + query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE parent = ? ORDER BY posted ASC, id ASC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + forum := &Forum{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + LastPost: stmt.ColumnInt64(2), + Author: stmt.ColumnInt(3), + Parent: stmt.ColumnInt(4), + Replies: stmt.ColumnInt(5), + Title: stmt.ColumnText(6), + Content: stmt.ColumnText(7), + db: db, + } + forums = append(forums, forum) + return nil + }, parentID) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve forum replies: %w", err) + } + + return forums, nil +} + +// ByAuthor retrieves forum posts by a specific author +func ByAuthor(db *database.DB, authorID int) ([]*Forum, error) { + var forums []*Forum + + query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE author = ? ORDER BY posted DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + forum := &Forum{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + LastPost: stmt.ColumnInt64(2), + Author: stmt.ColumnInt(3), + Parent: stmt.ColumnInt(4), + Replies: stmt.ColumnInt(5), + Title: stmt.ColumnText(6), + Content: stmt.ColumnText(7), + db: db, + } + forums = append(forums, forum) + return nil + }, authorID) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve forum posts by author: %w", err) + } + + return forums, nil +} + +// Recent retrieves the most recent forum activity (limited by count) +func Recent(db *database.DB, limit int) ([]*Forum, error) { + var forums []*Forum + + query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum ORDER BY last_post DESC, id DESC LIMIT ?" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + forum := &Forum{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + LastPost: stmt.ColumnInt64(2), + Author: stmt.ColumnInt(3), + Parent: stmt.ColumnInt(4), + Replies: stmt.ColumnInt(5), + Title: stmt.ColumnText(6), + Content: stmt.ColumnText(7), + db: db, + } + forums = append(forums, forum) + return nil + }, limit) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve recent forum posts: %w", err) + } + + return forums, nil +} + +// Search retrieves forum posts containing the search term in title or content +func Search(db *database.DB, term string) ([]*Forum, error) { + var forums []*Forum + + query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?) ORDER BY last_post DESC, id DESC" + searchTerm := "%" + term + "%" + + err := db.Query(query, func(stmt *sqlite.Stmt) error { + forum := &Forum{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + LastPost: stmt.ColumnInt64(2), + Author: stmt.ColumnInt(3), + Parent: stmt.ColumnInt(4), + Replies: stmt.ColumnInt(5), + Title: stmt.ColumnText(6), + Content: stmt.ColumnText(7), + db: db, + } + forums = append(forums, forum) + return nil + }, searchTerm, searchTerm) + + if err != nil { + return nil, fmt.Errorf("failed to search forum posts: %w", err) + } + + return forums, nil +} + +// Since retrieves forum posts with activity since a specific timestamp +func Since(db *database.DB, since int64) ([]*Forum, error) { + var forums []*Forum + + query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE last_post >= ? ORDER BY last_post DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + forum := &Forum{ + ID: stmt.ColumnInt(0), + Posted: stmt.ColumnInt64(1), + LastPost: stmt.ColumnInt64(2), + Author: stmt.ColumnInt(3), + Parent: stmt.ColumnInt(4), + Replies: stmt.ColumnInt(5), + Title: stmt.ColumnText(6), + Content: stmt.ColumnText(7), + db: db, + } + forums = append(forums, forum) + return nil + }, since) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve forum posts since timestamp: %w", err) + } + + return forums, nil +} + +// Save updates an existing forum post in the database +func (f *Forum) Save() error { + if f.ID == 0 { + return fmt.Errorf("cannot save forum post without ID") + } + + query := `UPDATE forum SET posted = ?, last_post = ?, author = ?, parent = ?, replies = ?, title = ?, content = ? WHERE id = ?` + return f.db.Exec(query, f.Posted, f.LastPost, f.Author, f.Parent, f.Replies, f.Title, f.Content, f.ID) +} + +// Delete removes the forum post from the database +func (f *Forum) Delete() error { + if f.ID == 0 { + return fmt.Errorf("cannot delete forum post without ID") + } + + query := "DELETE FROM forum WHERE id = ?" + return f.db.Exec(query, f.ID) +} + +// PostedTime returns the posted timestamp as a time.Time +func (f *Forum) PostedTime() time.Time { + return time.Unix(f.Posted, 0) +} + +// LastPostTime returns the last post timestamp as a time.Time +func (f *Forum) LastPostTime() time.Time { + return time.Unix(f.LastPost, 0) +} + +// SetPostedTime sets the posted timestamp from a time.Time +func (f *Forum) SetPostedTime(t time.Time) { + f.Posted = t.Unix() +} + +// SetLastPostTime sets the last post timestamp from a time.Time +func (f *Forum) SetLastPostTime(t time.Time) { + f.LastPost = t.Unix() +} + +// IsThread returns true if this is a top-level thread (parent = 0) +func (f *Forum) IsThread() bool { + return f.Parent == 0 +} + +// IsReply returns true if this is a reply to another post (parent > 0) +func (f *Forum) IsReply() bool { + return f.Parent > 0 +} + +// HasReplies returns true if this post has replies +func (f *Forum) HasReplies() bool { + return f.Replies > 0 +} + +// IsRecentActivity returns true if there has been activity within the last 24 hours +func (f *Forum) IsRecentActivity() bool { + return time.Since(f.LastPostTime()) < 24*time.Hour +} + +// ActivityAge returns how long ago the last activity occurred +func (f *Forum) ActivityAge() time.Duration { + return time.Since(f.LastPostTime()) +} + +// PostAge returns how long ago the post was originally made +func (f *Forum) PostAge() time.Duration { + return time.Since(f.PostedTime()) +} + +// IsAuthor returns true if the given user ID is the author of this post +func (f *Forum) IsAuthor(userID int) bool { + return f.Author == userID +} + +// Preview returns a truncated version of the content for previews +func (f *Forum) Preview(maxLength int) string { + if len(f.Content) <= maxLength { + return f.Content + } + + if maxLength < 3 { + return f.Content[:maxLength] + } + + return f.Content[:maxLength-3] + "..." +} + +// WordCount returns the number of words in the content +func (f *Forum) WordCount() int { + if f.Content == "" { + return 0 + } + + // Simple word count by splitting on whitespace + words := 0 + inWord := false + + for _, char := range f.Content { + if char == ' ' || char == '\t' || char == '\n' || char == '\r' { + if inWord { + words++ + inWord = false + } + } else { + inWord = true + } + } + + if inWord { + words++ + } + + return words +} + +// Length returns the character length of the content +func (f *Forum) Length() int { + return len(f.Content) +} + +// Contains returns true if the title or content contains the given term (case-insensitive) +func (f *Forum) Contains(term string) bool { + lowerTerm := strings.ToLower(term) + return strings.Contains(strings.ToLower(f.Title), lowerTerm) || + strings.Contains(strings.ToLower(f.Content), lowerTerm) +} + +// UpdateLastPost updates the last_post timestamp to current time +func (f *Forum) UpdateLastPost() { + f.LastPost = time.Now().Unix() +} + +// IncrementReplies increments the reply count +func (f *Forum) IncrementReplies() { + f.Replies++ +} + +// DecrementReplies decrements the reply count (minimum 0) +func (f *Forum) DecrementReplies() { + if f.Replies > 0 { + f.Replies-- + } +} + +// GetReplies retrieves all direct replies to this post +func (f *Forum) GetReplies() ([]*Forum, error) { + return ByParent(f.db, f.ID) +} + +// GetThread retrieves the parent thread (if this is a reply) or returns self (if this is a thread) +func (f *Forum) GetThread() (*Forum, error) { + if f.IsThread() { + return f, nil + } + return Find(f.db, f.Parent) +} \ No newline at end of file diff --git a/internal/forum/forum_test.go b/internal/forum/forum_test.go new file mode 100644 index 0000000..0561d19 --- /dev/null +++ b/internal/forum/forum_test.go @@ -0,0 +1,665 @@ +package forum + +import ( + "os" + "testing" + "time" + + "dk/internal/database" +) + +func setupTestDB(t *testing.T) *database.DB { + testDB := "test_forum.db" + t.Cleanup(func() { + os.Remove(testDB) + }) + + db, err := database.Open(testDB) + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + // Create forum table + createTable := `CREATE TABLE forum ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + posted INTEGER NOT NULL DEFAULT (unixepoch()), + last_post INTEGER NOT NULL DEFAULT (unixepoch()), + author INTEGER NOT NULL, + parent INTEGER NOT NULL DEFAULT 0, + replies INTEGER NOT NULL DEFAULT 0, + title TEXT NOT NULL, + content TEXT NOT NULL + )` + + if err := db.Exec(createTable); err != nil { + t.Fatalf("Failed to create forum table: %v", err) + } + + // Insert test data with specific timestamps for predictable testing + now := time.Now().Unix() + testForum := `INSERT INTO forum (posted, last_post, author, parent, replies, title, content) VALUES + (?, ?, 1, 0, 2, 'Welcome to the Game!', 'This is the first thread about our awesome game.'), + (?, ?, 2, 1, 0, 'Re: Welcome to the Game!', 'Thanks! I am excited to start playing.'), + (?, ?, 3, 1, 0, 'Re: Welcome to the Game!', 'Great game so far, loving the mechanics!'), + (?, ?, 1, 0, 1, 'Bug Reports', 'Please report any bugs you find here.'), + (?, ?, 2, 4, 0, 'Re: Bug Reports', 'Found a small issue with spell casting.'), + (?, ?, 3, 0, 0, 'Strategy Discussion', 'Let us discuss optimal character builds and strategies.')` + + timestamps := []interface{}{ + now - 86400*7, now - 86400*1, // Thread 1, last activity 1 day ago + now - 86400*6, now - 86400*6, // Reply 1 + now - 86400*1, now - 86400*1, // Reply 2 (most recent activity on thread 1) + now - 86400*3, now - 86400*2, // Thread 2, last activity 2 days ago + now - 86400*2, now - 86400*2, // Reply to thread 2 (most recent activity on thread 2) + now - 3600*2, now - 3600*2, // Thread 3, 2 hours ago (most recent) + } + + if err := db.Exec(testForum, timestamps...); err != nil { + t.Fatalf("Failed to insert test forum data: %v", err) + } + + return db +} + +func TestFind(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test finding existing forum post + post, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find forum post: %v", err) + } + + if post.ID != 1 { + t.Errorf("Expected ID 1, got %d", post.ID) + } + if post.Author != 1 { + t.Errorf("Expected author 1, got %d", post.Author) + } + if post.Parent != 0 { + t.Errorf("Expected parent 0, got %d", post.Parent) + } + if post.Replies != 2 { + t.Errorf("Expected replies 2, got %d", post.Replies) + } + if post.Title != "Welcome to the Game!" { + t.Errorf("Expected title 'Welcome to the Game!', got '%s'", post.Title) + } + if post.Content != "This is the first thread about our awesome game." { + t.Errorf("Expected specific content, got '%s'", post.Content) + } + if post.Posted == 0 { + t.Error("Expected non-zero posted timestamp") + } + if post.LastPost == 0 { + t.Error("Expected non-zero last_post timestamp") + } + + // Test finding non-existent forum post + _, err = Find(db, 999) + if err == nil { + t.Error("Expected error when finding non-existent forum post") + } +} + +func TestAll(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + posts, err := All(db) + if err != nil { + t.Fatalf("Failed to get all forum posts: %v", err) + } + + if len(posts) != 6 { + t.Errorf("Expected 6 forum posts, got %d", len(posts)) + } + + // Check ordering (by last_post DESC) + if len(posts) >= 2 { + if posts[0].LastPost < posts[1].LastPost { + t.Error("Expected posts to be ordered by last_post (newest first)") + } + } + + // First post should be the most recent activity (2 hours ago) + if posts[0].Title != "Strategy Discussion" { + t.Errorf("Expected newest activity to be 'Strategy Discussion', got '%s'", posts[0].Title) + } +} + +func TestThreads(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + threads, err := Threads(db) + if err != nil { + t.Fatalf("Failed to get forum threads: %v", err) + } + + if len(threads) != 3 { + t.Errorf("Expected 3 threads, got %d", len(threads)) + } + + // Verify all are threads (parent = 0) + for _, thread := range threads { + if thread.Parent != 0 { + t.Errorf("Expected thread to have parent 0, got %d", thread.Parent) + } + if !thread.IsThread() { + t.Errorf("Expected IsThread() to return true for thread %d", thread.ID) + } + if thread.IsReply() { + t.Errorf("Expected IsReply() to return false for thread %d", thread.ID) + } + } + + // Check ordering (by last_post DESC) + if len(threads) >= 2 { + if threads[0].LastPost < threads[1].LastPost { + t.Error("Expected threads to be ordered by last activity") + } + } +} + +func TestByParent(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test replies to thread 1 + replies, err := ByParent(db, 1) + if err != nil { + t.Fatalf("Failed to get replies: %v", err) + } + + if len(replies) != 2 { + t.Errorf("Expected 2 replies to thread 1, got %d", len(replies)) + } + + // Verify all are replies to thread 1 + for _, reply := range replies { + if reply.Parent != 1 { + t.Errorf("Expected reply to have parent 1, got %d", reply.Parent) + } + if !reply.IsReply() { + t.Errorf("Expected IsReply() to return true for reply %d", reply.ID) + } + if reply.IsThread() { + t.Errorf("Expected IsThread() to return false for reply %d", reply.ID) + } + } + + // Check ordering (by posted ASC for replies) + if len(replies) == 2 { + if replies[0].Posted > replies[1].Posted { + t.Error("Expected replies to be ordered by posted time (oldest first)") + } + } + + // Test no replies case + noReplies, err := ByParent(db, 6) // Thread 3 has no replies + if err != nil { + t.Fatalf("Failed to get replies for thread with no replies: %v", err) + } + + if len(noReplies) != 0 { + t.Errorf("Expected 0 replies for thread 3, got %d", len(noReplies)) + } +} + +func TestByAuthor(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test posts by author 1 + author1Posts, err := ByAuthor(db, 1) + if err != nil { + t.Fatalf("Failed to get posts by author: %v", err) + } + + if len(author1Posts) != 2 { + t.Errorf("Expected 2 posts by author 1, got %d", len(author1Posts)) + } + + // Verify all posts are by author 1 + for _, post := range author1Posts { + if post.Author != 1 { + t.Errorf("Expected author 1, got %d", post.Author) + } + } + + // Check ordering (by posted DESC) + if len(author1Posts) == 2 { + if author1Posts[0].Posted < author1Posts[1].Posted { + t.Error("Expected posts to be ordered by posted time (newest first)") + } + } + + // Test author with no posts + noPosts, err := ByAuthor(db, 999) + if err != nil { + t.Fatalf("Failed to query non-existent author: %v", err) + } + + if len(noPosts) != 0 { + t.Errorf("Expected 0 posts by non-existent author, got %d", len(noPosts)) + } +} + +func TestRecent(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test getting 3 most recent posts + recentPosts, err := Recent(db, 3) + if err != nil { + t.Fatalf("Failed to get recent forum posts: %v", err) + } + + if len(recentPosts) != 3 { + t.Errorf("Expected 3 recent posts, got %d", len(recentPosts)) + } + + // Check ordering (by last_post DESC) + if len(recentPosts) >= 2 { + if recentPosts[0].LastPost < recentPosts[1].LastPost { + t.Error("Expected recent posts to be ordered by last activity") + } + } + + // Test getting more posts than exist + allRecentPosts, err := Recent(db, 10) + if err != nil { + t.Fatalf("Failed to get recent posts with high limit: %v", err) + } + + if len(allRecentPosts) != 6 { + t.Errorf("Expected 6 posts (all available), got %d", len(allRecentPosts)) + } +} + +func TestSearch(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test searching for "game" + gamePosts, err := Search(db, "game") + if err != nil { + t.Fatalf("Failed to search forum posts: %v", err) + } + + expectedCount := 3 // Welcome thread title, content, and replies containing "game" + if len(gamePosts) != expectedCount { + t.Errorf("Expected %d posts containing 'game', got %d", expectedCount, len(gamePosts)) + } + + // Verify all posts contain the search term + for _, post := range gamePosts { + if !post.Contains("game") { + t.Errorf("Post '%s' does not contain search term 'game'", post.Title) + } + } + + // Test case insensitive search + gamePostsUpper, err := Search(db, "GAME") + if err != nil { + t.Fatalf("Failed to search with uppercase: %v", err) + } + + if len(gamePostsUpper) != expectedCount { + t.Error("Expected case insensitive search to find same results") + } + + // Test search with no results + noResults, err := Search(db, "nonexistentterm") + if err != nil { + t.Fatalf("Failed to search for non-existent term: %v", err) + } + + if len(noResults) != 0 { + t.Errorf("Expected 0 results for non-existent term, got %d", len(noResults)) + } +} + +func TestSince(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test posts with activity since 3 days ago + threeDaysAgo := time.Now().AddDate(0, 0, -3).Unix() + recentPosts, err := Since(db, threeDaysAgo) + if err != nil { + t.Fatalf("Failed to get posts since timestamp: %v", err) + } + + // Should get posts with last_post within last 3 days (includes replies) + expectedCount := 5 // Thread 1 (1 day ago), Reply 2 to Thread 1, Thread 2 (2 days ago), Reply to Thread 2, Thread 3 (2 hours ago) + if len(recentPosts) != expectedCount { + t.Errorf("Expected %d posts with activity since 3 days ago, got %d", expectedCount, len(recentPosts)) + } + + // Verify all posts have last_post since the timestamp + for _, post := range recentPosts { + if post.LastPost < threeDaysAgo { + t.Errorf("Post with last_post %d is before the 'since' timestamp %d", post.LastPost, threeDaysAgo) + } + } + + // Test with future timestamp (should return no posts) + futurePosts, err := Since(db, time.Now().Add(time.Hour).Unix()) + if err != nil { + t.Fatalf("Failed to query future timestamp: %v", err) + } + + if len(futurePosts) != 0 { + t.Errorf("Expected 0 posts since future timestamp, got %d", len(futurePosts)) + } +} + +func TestBuilder(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create new thread using builder + testTime := time.Now() + post, err := NewBuilder(db). + WithAuthor(5). + WithTitle("Test Thread"). + WithContent("This is a test thread created with the builder"). + WithPostedTime(testTime). + WithLastPostTime(testTime). + AsThread(). + Create() + + if err != nil { + t.Fatalf("Failed to create forum post with builder: %v", err) + } + + if post.ID == 0 { + t.Error("Expected non-zero ID after creation") + } + if post.Author != 5 { + t.Errorf("Expected author 5, got %d", post.Author) + } + if post.Title != "Test Thread" { + t.Errorf("Expected title 'Test Thread', got '%s'", post.Title) + } + if post.Content != "This is a test thread created with the builder" { + t.Errorf("Expected specific content, got '%s'", post.Content) + } + if post.Posted != testTime.Unix() { + t.Errorf("Expected posted time %d, got %d", testTime.Unix(), post.Posted) + } + if post.Parent != 0 { + t.Errorf("Expected parent 0 (thread), got %d", post.Parent) + } + if post.Replies != 0 { + t.Errorf("Expected replies 0, got %d", post.Replies) + } + + // Create reply using builder + reply, err := NewBuilder(db). + WithAuthor(6). + WithTitle("Re: Test Thread"). + WithContent("This is a reply to the test thread"). + AsReply(post.ID). + Create() + + if err != nil { + t.Fatalf("Failed to create reply with builder: %v", err) + } + + if reply.Parent != post.ID { + t.Errorf("Expected parent %d, got %d", post.ID, reply.Parent) + } + if !reply.IsReply() { + t.Error("Expected reply to be identified as reply") + } + + // Verify posts were saved to database + foundPost, err := Find(db, post.ID) + if err != nil { + t.Fatalf("Failed to find created post: %v", err) + } + + if foundPost.Title != "Test Thread" { + t.Errorf("Created post not found in database") + } + + // Test builder with default timestamp + defaultPost, err := NewBuilder(db). + WithAuthor(7). + WithTitle("Default Time Post"). + WithContent("Post with default timestamps"). + Create() + + if err != nil { + t.Fatalf("Failed to create post with default timestamp: %v", err) + } + + // Should have recent timestamps (within last minute) + if time.Since(defaultPost.PostedTime()) > time.Minute { + t.Error("Expected default posted timestamp to be recent") + } + if time.Since(defaultPost.LastPostTime()) > time.Minute { + t.Error("Expected default last_post timestamp to be recent") + } +} + +func TestSave(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + post, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find forum post: %v", err) + } + + // Modify post + post.Title = "Updated Welcome Thread" + post.Content = "This content has been updated by moderator" + post.Replies = 3 + post.UpdateLastPost() + + // Save changes + err = post.Save() + if err != nil { + t.Fatalf("Failed to save forum post: %v", err) + } + + // Verify changes were saved + updatedPost, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find updated post: %v", err) + } + + if updatedPost.Title != "Updated Welcome Thread" { + t.Errorf("Expected updated title 'Updated Welcome Thread', got '%s'", updatedPost.Title) + } + if updatedPost.Content != "This content has been updated by moderator" { + t.Errorf("Expected updated content, got '%s'", updatedPost.Content) + } + if updatedPost.Replies != 3 { + t.Errorf("Expected updated replies 3, got %d", updatedPost.Replies) + } +} + +func TestDelete(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + post, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find forum post: %v", err) + } + + // Delete post + err = post.Delete() + if err != nil { + t.Fatalf("Failed to delete forum post: %v", err) + } + + // Verify post was deleted + _, err = Find(db, 1) + if err == nil { + t.Error("Expected error when finding deleted post") + } +} + +func TestUtilityMethods(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + post, _ := Find(db, 1) + + // Test time methods + postedTime := post.PostedTime() + if postedTime.IsZero() { + t.Error("Expected non-zero posted time") + } + + lastPostTime := post.LastPostTime() + if lastPostTime.IsZero() { + t.Error("Expected non-zero last post time") + } + + // Test SetPostedTime and SetLastPostTime + newTime := time.Now().Add(-2 * time.Hour) + post.SetPostedTime(newTime) + post.SetLastPostTime(newTime) + + if post.Posted != newTime.Unix() { + t.Errorf("Expected posted timestamp %d, got %d", newTime.Unix(), post.Posted) + } + if post.LastPost != newTime.Unix() { + t.Errorf("Expected last_post timestamp %d, got %d", newTime.Unix(), post.LastPost) + } + + // Test activity age methods + activityAge := post.ActivityAge() + if activityAge < 0 { + t.Error("Expected positive activity age") + } + + postAge := post.PostAge() + if postAge < 0 { + t.Error("Expected positive post age") + } + + // Test IsRecentActivity + post.UpdateLastPost() // Set to now + if !post.IsRecentActivity() { + t.Error("Expected post with current timestamp to have recent activity") + } + + // Test IsAuthor + if !post.IsAuthor(post.Author) { + t.Error("Expected IsAuthor to return true for correct author") + } + if post.IsAuthor(999) { + t.Error("Expected IsAuthor to return false for incorrect author") + } + + // Test HasReplies + if !post.HasReplies() { + t.Error("Expected post with replies > 0 to HasReplies") + } + + // Test Preview + longContent := "This is a very long forum post content that should be truncated when preview is called for display purposes" + post.Content = longContent + + preview := post.Preview(20) + if len(preview) > 20 { + t.Errorf("Expected preview length <= 20, got %d", len(preview)) + } + if preview[len(preview)-3:] != "..." { + t.Error("Expected preview to end with ellipsis") + } + + shortPreview := post.Preview(200) // Longer than content + if shortPreview != longContent { + t.Error("Expected short content to not be truncated") + } + + // Test WordCount + post.Content = "This is a test with five words" + wordCount := post.WordCount() + if wordCount != 7 { + t.Errorf("Expected 7 words, got %d", wordCount) + } + + // Test Length + expectedLength := len(post.Content) + if post.Length() != expectedLength { + t.Errorf("Expected length %d, got %d", expectedLength, post.Length()) + } + + // Test Contains + if !post.Contains("test") { + t.Error("Expected post to contain 'test'") + } + if !post.Contains("TEST") { // Case insensitive + t.Error("Expected Contains to be case insensitive") + } + if post.Contains("nonexistent") { + t.Error("Expected post not to contain 'nonexistent'") + } + + // Test reply count methods + originalReplies := post.Replies + post.IncrementReplies() + if post.Replies != originalReplies+1 { + t.Errorf("Expected replies to be incremented to %d, got %d", originalReplies+1, post.Replies) + } + + post.DecrementReplies() + if post.Replies != originalReplies { + t.Errorf("Expected replies to be decremented back to %d, got %d", originalReplies, post.Replies) + } + + // Test DecrementReplies with 0 replies + post.Replies = 0 + post.DecrementReplies() + if post.Replies != 0 { + t.Errorf("Expected replies to stay at 0 when decrementing from 0, got %d", post.Replies) + } +} + +func TestRelationshipMethods(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test GetReplies on a thread + thread, _ := Find(db, 1) // Thread with 2 replies + replies, err := thread.GetReplies() + if err != nil { + t.Fatalf("Failed to get replies: %v", err) + } + + if len(replies) != 2 { + t.Errorf("Expected 2 replies, got %d", len(replies)) + } + + // Test GetThread on a reply + reply, _ := Find(db, 2) // Reply to thread 1 + parentThread, err := reply.GetThread() + if err != nil { + t.Fatalf("Failed to get parent thread: %v", err) + } + + if parentThread.ID != 1 { + t.Errorf("Expected parent thread ID 1, got %d", parentThread.ID) + } + + // Test GetThread on a thread (should return self) + threadSelf, err := thread.GetThread() + if err != nil { + t.Fatalf("Failed to get thread (self): %v", err) + } + + if threadSelf.ID != thread.ID { + t.Errorf("Expected GetThread on thread to return self, got ID %d", threadSelf.ID) + } +} \ No newline at end of file