create forum package
This commit is contained in:
parent
42e090b05f
commit
96857e8110
131
internal/forum/builder.go
Normal file
131
internal/forum/builder.go
Normal file
@ -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
|
||||||
|
}
|
627
internal/forum/doc.go
Normal file
627
internal/forum/doc.go
Normal file
@ -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
|
409
internal/forum/forum.go
Normal file
409
internal/forum/forum.go
Normal file
@ -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)
|
||||||
|
}
|
665
internal/forum/forum_test.go
Normal file
665
internal/forum/forum_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user