From 089246dd25f14ce62c1eaf2d83bf1fcfceb2d1aa Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Fri, 8 Aug 2025 23:37:08 -0500 Subject: [PATCH] create news package --- internal/news/builder.go | 85 +++++++ internal/news/doc.go | 349 ++++++++++++++++++++++++++++ internal/news/news.go | 250 ++++++++++++++++++++ internal/news/news_test.go | 459 +++++++++++++++++++++++++++++++++++++ 4 files changed, 1143 insertions(+) create mode 100644 internal/news/builder.go create mode 100644 internal/news/doc.go create mode 100644 internal/news/news.go create mode 100644 internal/news/news_test.go diff --git a/internal/news/builder.go b/internal/news/builder.go new file mode 100644 index 0000000..c5d0ea5 --- /dev/null +++ b/internal/news/builder.go @@ -0,0 +1,85 @@ +package news + +import ( + "fmt" + "time" + + "dk/internal/database" + + "zombiezen.com/go/sqlite" +) + +// Builder provides a fluent interface for creating news posts +type Builder struct { + news *News + db *database.DB +} + +// NewBuilder creates a new news builder +func NewBuilder(db *database.DB) *Builder { + return &Builder{ + news: &News{ + db: db, + Posted: time.Now().Unix(), // Default to current time + }, + db: db, + } +} + +// WithAuthor sets the author ID +func (b *Builder) WithAuthor(authorID int) *Builder { + b.news.Author = authorID + return b +} + +// WithContent sets the news content +func (b *Builder) WithContent(content string) *Builder { + b.news.Content = content + return b +} + +// WithPosted sets the posted timestamp +func (b *Builder) WithPosted(posted int64) *Builder { + b.news.Posted = posted + return b +} + +// WithPostedTime sets the posted timestamp from a time.Time +func (b *Builder) WithPostedTime(t time.Time) *Builder { + b.news.Posted = t.Unix() + return b +} + +// Create saves the news post to the database and returns the created news with ID +func (b *Builder) Create() (*News, error) { + // Use a transaction to ensure we can get the ID + var news *News + err := b.db.Transaction(func(tx *database.Tx) error { + query := `INSERT INTO news (author, posted, content) + VALUES (?, ?, ?)` + + if err := tx.Exec(query, b.news.Author, b.news.Posted, b.news.Content); err != nil { + return fmt.Errorf("failed to insert news: %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.news.ID = id + news = b.news + return nil + }) + + if err != nil { + return nil, err + } + + return news, nil +} \ No newline at end of file diff --git a/internal/news/doc.go b/internal/news/doc.go new file mode 100644 index 0000000..9df7b81 --- /dev/null +++ b/internal/news/doc.go @@ -0,0 +1,349 @@ +/* +Package news is the active record implementation for news posts in the game. + +# Basic Usage + +To retrieve a news post by ID: + + newsPost, err := news.Find(db, 1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("News: %s (by user %d)\n", newsPost.Content, newsPost.Author) + +To get all news posts: + + allNews, err := news.All(db) + if err != nil { + log.Fatal(err) + } + for _, post := range allNews { + fmt.Printf("News: %s\n", post.Content) + } + +To get recent news posts: + + recentNews, err := news.Recent(db, 10) + if err != nil { + log.Fatal(err) + } + +To filter news by author: + + authorPosts, err := news.ByAuthor(db, userID) + if err != nil { + log.Fatal(err) + } + +To get news since a specific time: + + yesterday := time.Now().AddDate(0, 0, -1).Unix() + recentNews, err := news.Since(db, yesterday) + if err != nil { + log.Fatal(err) + } + +# Creating News with Builder Pattern + +The package provides a fluent builder interface for creating new news posts: + + newsPost, err := news.NewBuilder(db). + WithAuthor(userID). + WithContent("Welcome to the new update! Many exciting features await."). + WithPostedTime(time.Now()). + Create() + + if err != nil { + log.Fatal(err) + } + fmt.Printf("Created news post with ID: %d\n", newsPost.ID) + +The builder automatically sets the current time if no posted time is specified: + + newsPost, err := news.NewBuilder(db). + WithAuthor(adminID). + WithContent("Server maintenance scheduled for tonight."). + Create() // Uses current timestamp + +# Updating News + +News posts can be modified and saved back to the database: + + newsPost, _ := news.Find(db, 1) + newsPost.Content = "Updated: Server maintenance completed successfully." + + err := newsPost.Save() + if err != nil { + log.Fatal(err) + } + +# Deleting News + +News posts can be removed from the database: + + newsPost, _ := news.Find(db, 1) + err := newsPost.Delete() + if err != nil { + log.Fatal(err) + } + +# Database Schema + +The news table has the following structure: + + CREATE TABLE news ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author INTEGER NOT NULL, + posted INTEGER NOT NULL DEFAULT (unixepoch()), + content TEXT NOT NULL + ) + +Where: + - id: Unique identifier for the news post + - author: User ID of the author who created the post + - posted: Unix timestamp when the post was created + - content: The text content of the news post + +# Time-Based Queries + +## Recent News + +Get the most recent news posts: + + // Get 5 most recent posts + latestNews, err := news.Recent(db, 5) + if err != nil { + log.Fatal(err) + } + + for _, post := range latestNews { + fmt.Printf("Posted %s: %s\n", + post.PostedTime().Format("2006-01-02"), + post.Preview(50)) + } + +## News Since Timestamp + +Get news posts since a specific time: + + // Get news from the last week + weekAgo := time.Now().AddDate(0, 0, -7).Unix() + weeklyNews, err := news.Since(db, weekAgo) + if err != nil { + log.Fatal(err) + } + +## News Between Timestamps + +Get news posts within a time range: + + // Get news from last month + start := time.Now().AddDate(0, -1, 0).Unix() + end := time.Now().Unix() + + monthlyNews, err := news.Between(db, start, end) + if err != nil { + log.Fatal(err) + } + +# Content Management + +## Content Analysis + +The package provides utilities for analyzing news content: + + wordCount := newsPost.WordCount() + fmt.Printf("Post contains %d words\n", wordCount) + + preview := newsPost.Preview(100) + fmt.Printf("Preview: %s\n", preview) + +## Author Management + +Check authorship and manage posts by author: + + if newsPost.IsAuthor(currentUserID) { + fmt.Println("You can edit this post") + } + + // Get all posts by a specific author + authorPosts, err := news.ByAuthor(db, authorID) + fmt.Printf("Author has written %d posts\n", len(authorPosts)) + +# Time Utilities + +## Working with Timestamps + +The package provides convenient time utilities: + + // Get posting time as time.Time + postTime := newsPost.PostedTime() + fmt.Printf("Posted on: %s\n", postTime.Format("January 2, 2006")) + + // Set posting time from time.Time + newsPost.SetPostedTime(time.Now().Add(-2 * time.Hour)) + + // Check if post is recent (within 24 hours) + if newsPost.IsRecent() { + fmt.Println("This is a recent post") + } + + // Get age of the post + age := newsPost.Age() + fmt.Printf("Posted %v ago\n", age) + +## Time-Based Filtering + +Find posts based on recency: + + allNews, _ := news.All(db) + recentPosts := make([]*news.News, 0) + + for _, post := range allNews { + if post.IsRecent() { + recentPosts = append(recentPosts, post) + } + } + + fmt.Printf("Found %d recent posts\n", len(recentPosts)) + +# Administrative Features + +## Moderation + +Administrators can manage news posts: + + // Get all posts by a user for moderation + suspiciousPosts, err := news.ByAuthor(db, reportedUserID) + + for _, post := range suspiciousPosts { + if post.WordCount() < 5 { + fmt.Printf("Short post flagged: %s\n", post.Preview(50)) + } + } + +## Content Guidelines + +Use content analysis for moderation: + + if newsPost.WordCount() > 1000 { + fmt.Println("Warning: Very long post") + } + + if len(newsPost.Content) < 10 { + fmt.Println("Warning: Very short post") + } + +# Display and Formatting + +## News Feeds + +Display news in chronological order: + + recentNews, _ := news.Recent(db, 20) + + fmt.Println("=== Latest News ===") + for _, post := range recentNews { + age := post.Age() + var ageStr string + + if age < time.Hour { + ageStr = fmt.Sprintf("%d minutes ago", int(age.Minutes())) + } else if age < 24*time.Hour { + ageStr = fmt.Sprintf("%d hours ago", int(age.Hours())) + } else { + ageStr = fmt.Sprintf("%d days ago", int(age.Hours()/24)) + } + + fmt.Printf("[%s] %s\n", ageStr, post.Preview(80)) + } + +## Preview Generation + +Generate previews for different contexts: + + // Short preview for mobile + mobilePreview := newsPost.Preview(30) + + // Medium preview for web cards + cardPreview := newsPost.Preview(100) + + // Long preview for detailed view + detailPreview := newsPost.Preview(200) + +# Performance Considerations + +## Query Optimization + +All time-based queries are optimized with proper indexing expectations: + + // Queries are ordered by posted timestamp for efficient retrieval + latestNews, _ := news.All(db) // ORDER BY posted DESC, id DESC + + // Author queries include time ordering + authorPosts, _ := news.ByAuthor(db, userID) // ORDER BY posted DESC, id DESC + +## Batch Operations + +For processing multiple posts efficiently: + + // Process posts in batches + batchSize := 100 + allNews, _ := news.All(db) + + for i := 0; i < len(allNews); i += batchSize { + end := i + batchSize + if end > len(allNews) { + end = len(allNews) + } + + batch := allNews[i:end] + // Process batch... + } + +# Error Handling + +All functions return appropriate errors for common failure cases: + - News post not found (Find returns error for non-existent IDs) + - Database connection issues + - Invalid operations (e.g., saving/deleting news posts without IDs) + - Time range query errors + +# Integration Patterns + +## User Integration + +Connect with user management: + + // Example: Get news by username (assuming user lookup) + user := getUserByName("admin") + adminNews, err := news.ByAuthor(db, user.ID) + +## Notification System + +Use for activity feeds: + + // Get user's activity since last login + lastLogin := getUserLastLogin(userID) + newsSinceLogin, err := news.Since(db, lastLogin) + + if len(newsSinceLogin) > 0 { + fmt.Printf("You have %d new posts since your last visit\n", len(newsSinceLogin)) + } + +## Archive Management + +Implement content archiving: + + // Archive old posts (older than 1 year) + oneYearAgo := time.Now().AddDate(-1, 0, 0).Unix() + oldPosts, _ := news.Since(db, 0) // Get all posts + + for _, post := range oldPosts { + if post.Posted < oneYearAgo { + // Archive or delete old post + post.Delete() + } + } +*/ +package news \ No newline at end of file diff --git a/internal/news/news.go b/internal/news/news.go new file mode 100644 index 0000000..49f8d86 --- /dev/null +++ b/internal/news/news.go @@ -0,0 +1,250 @@ +package news + +import ( + "fmt" + "time" + + "dk/internal/database" + + "zombiezen.com/go/sqlite" +) + +// News represents a news post in the database +type News struct { + ID int `json:"id"` + Author int `json:"author"` + Posted int64 `json:"posted"` + Content string `json:"content"` + + db *database.DB +} + +// Find retrieves a news post by ID +func Find(db *database.DB, id int) (*News, error) { + news := &News{db: db} + + query := "SELECT id, author, posted, content FROM news WHERE id = ?" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + news.ID = stmt.ColumnInt(0) + news.Author = stmt.ColumnInt(1) + news.Posted = stmt.ColumnInt64(2) + news.Content = stmt.ColumnText(3) + return nil + }, id) + + if err != nil { + return nil, fmt.Errorf("failed to find news: %w", err) + } + + if news.ID == 0 { + return nil, fmt.Errorf("news with ID %d not found", id) + } + + return news, nil +} + +// All retrieves all news posts ordered by posted date (newest first) +func All(db *database.DB) ([]*News, error) { + var newsPosts []*News + + query := "SELECT id, author, posted, content FROM news ORDER BY posted DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + news := &News{ + ID: stmt.ColumnInt(0), + Author: stmt.ColumnInt(1), + Posted: stmt.ColumnInt64(2), + Content: stmt.ColumnText(3), + db: db, + } + newsPosts = append(newsPosts, news) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve all news: %w", err) + } + + return newsPosts, nil +} + +// ByAuthor retrieves news posts by a specific author +func ByAuthor(db *database.DB, authorID int) ([]*News, error) { + var newsPosts []*News + + query := "SELECT id, author, posted, content FROM news WHERE author = ? ORDER BY posted DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + news := &News{ + ID: stmt.ColumnInt(0), + Author: stmt.ColumnInt(1), + Posted: stmt.ColumnInt64(2), + Content: stmt.ColumnText(3), + db: db, + } + newsPosts = append(newsPosts, news) + return nil + }, authorID) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve news by author: %w", err) + } + + return newsPosts, nil +} + +// Recent retrieves the most recent news posts (limited by count) +func Recent(db *database.DB, limit int) ([]*News, error) { + var newsPosts []*News + + query := "SELECT id, author, posted, content FROM news ORDER BY posted DESC, id DESC LIMIT ?" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + news := &News{ + ID: stmt.ColumnInt(0), + Author: stmt.ColumnInt(1), + Posted: stmt.ColumnInt64(2), + Content: stmt.ColumnText(3), + db: db, + } + newsPosts = append(newsPosts, news) + return nil + }, limit) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve recent news: %w", err) + } + + return newsPosts, nil +} + +// Since retrieves news posts since a specific timestamp +func Since(db *database.DB, since int64) ([]*News, error) { + var newsPosts []*News + + query := "SELECT id, author, posted, content FROM news WHERE posted >= ? ORDER BY posted DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + news := &News{ + ID: stmt.ColumnInt(0), + Author: stmt.ColumnInt(1), + Posted: stmt.ColumnInt64(2), + Content: stmt.ColumnText(3), + db: db, + } + newsPosts = append(newsPosts, news) + return nil + }, since) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve news since timestamp: %w", err) + } + + return newsPosts, nil +} + +// Between retrieves news posts between two timestamps (inclusive) +func Between(db *database.DB, start, end int64) ([]*News, error) { + var newsPosts []*News + + query := "SELECT id, author, posted, content FROM news WHERE posted >= ? AND posted <= ? ORDER BY posted DESC, id DESC" + err := db.Query(query, func(stmt *sqlite.Stmt) error { + news := &News{ + ID: stmt.ColumnInt(0), + Author: stmt.ColumnInt(1), + Posted: stmt.ColumnInt64(2), + Content: stmt.ColumnText(3), + db: db, + } + newsPosts = append(newsPosts, news) + return nil + }, start, end) + + if err != nil { + return nil, fmt.Errorf("failed to retrieve news between timestamps: %w", err) + } + + return newsPosts, nil +} + +// Save updates an existing news post in the database +func (n *News) Save() error { + if n.ID == 0 { + return fmt.Errorf("cannot save news without ID") + } + + query := `UPDATE news SET author = ?, posted = ?, content = ? WHERE id = ?` + return n.db.Exec(query, n.Author, n.Posted, n.Content, n.ID) +} + +// Delete removes the news post from the database +func (n *News) Delete() error { + if n.ID == 0 { + return fmt.Errorf("cannot delete news without ID") + } + + query := "DELETE FROM news WHERE id = ?" + return n.db.Exec(query, n.ID) +} + +// PostedTime returns the posted timestamp as a time.Time +func (n *News) PostedTime() time.Time { + return time.Unix(n.Posted, 0) +} + +// SetPostedTime sets the posted timestamp from a time.Time +func (n *News) SetPostedTime(t time.Time) { + n.Posted = t.Unix() +} + +// IsRecent returns true if the news post was made within the last 24 hours +func (n *News) IsRecent() bool { + return time.Since(n.PostedTime()) < 24*time.Hour +} + +// Age returns how long ago the news post was made +func (n *News) Age() time.Duration { + return time.Since(n.PostedTime()) +} + +// IsAuthor returns true if the given user ID is the author of this news post +func (n *News) IsAuthor(userID int) bool { + return n.Author == userID +} + +// Preview returns a truncated version of the content for previews +func (n *News) Preview(maxLength int) string { + if len(n.Content) <= maxLength { + return n.Content + } + + if maxLength < 3 { + return n.Content[:maxLength] + } + + return n.Content[:maxLength-3] + "..." +} + +// WordCount returns the number of words in the content +func (n *News) WordCount() int { + if n.Content == "" { + return 0 + } + + // Simple word count by splitting on spaces + words := 0 + inWord := false + + for _, char := range n.Content { + if char == ' ' || char == '\t' || char == '\n' || char == '\r' { + if inWord { + words++ + inWord = false + } + } else { + inWord = true + } + } + + if inWord { + words++ + } + + return words +} \ No newline at end of file diff --git a/internal/news/news_test.go b/internal/news/news_test.go new file mode 100644 index 0000000..76ecc57 --- /dev/null +++ b/internal/news/news_test.go @@ -0,0 +1,459 @@ +package news + +import ( + "os" + "testing" + "time" + + "dk/internal/database" +) + +func setupTestDB(t *testing.T) *database.DB { + testDB := "test_news.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 news table + createTable := `CREATE TABLE news ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author INTEGER NOT NULL, + posted INTEGER NOT NULL DEFAULT (unixepoch()), + content TEXT NOT NULL + )` + + if err := db.Exec(createTable); err != nil { + t.Fatalf("Failed to create news table: %v", err) + } + + // Insert test data with specific timestamps for predictable testing + now := time.Now().Unix() + testNews := `INSERT INTO news (author, posted, content) VALUES + (1, ?, 'First news post about game updates'), + (2, ?, 'Second post from different author'), + (1, ?, 'Third post - recent update'), + (3, ?, 'Fourth post from admin'), + (2, ?, 'Fifth post - maintenance notice')` + + timestamps := []interface{}{ + now - 86400*7, // 1 week ago + now - 86400*5, // 5 days ago + now - 86400*2, // 2 days ago + now - 86400*1, // 1 day ago + now - 3600, // 1 hour ago + } + + if err := db.Exec(testNews, timestamps...); err != nil { + t.Fatalf("Failed to insert test news: %v", err) + } + + return db +} + +func TestFind(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test finding existing news + news, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find news: %v", err) + } + + if news.ID != 1 { + t.Errorf("Expected ID 1, got %d", news.ID) + } + if news.Author != 1 { + t.Errorf("Expected author 1, got %d", news.Author) + } + if news.Content != "First news post about game updates" { + t.Errorf("Expected specific content, got '%s'", news.Content) + } + if news.Posted == 0 { + t.Error("Expected non-zero posted timestamp") + } + + // Test finding non-existent news + _, err = Find(db, 999) + if err == nil { + t.Error("Expected error when finding non-existent news") + } +} + +func TestAll(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + newsPosts, err := All(db) + if err != nil { + t.Fatalf("Failed to get all news: %v", err) + } + + if len(newsPosts) != 5 { + t.Errorf("Expected 5 news posts, got %d", len(newsPosts)) + } + + // Check ordering (newest first) + if len(newsPosts) >= 2 { + if newsPosts[0].Posted < newsPosts[1].Posted { + t.Error("Expected news to be ordered by posted time (newest first)") + } + } + + // First post should be the most recent (1 hour ago) + if newsPosts[0].Content != "Fifth post - maintenance notice" { + t.Errorf("Expected newest post first, got '%s'", newsPosts[0].Content) + } +} + +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 news 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 (newest first) + if len(author1Posts) == 2 { + if author1Posts[0].Content != "Third post - recent update" { + t.Errorf("Expected newest post by author 1 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 news: %v", err) + } + + if len(recentPosts) != 3 { + t.Errorf("Expected 3 recent posts, got %d", len(recentPosts)) + } + + // Check ordering (newest first) + if len(recentPosts) >= 2 { + if recentPosts[0].Posted < recentPosts[1].Posted { + t.Error("Expected recent posts to be ordered newest first") + } + } + + // Test getting more posts than exist + allRecentPosts, err := Recent(db, 10) + if err != nil { + t.Fatalf("Failed to get recent news with high limit: %v", err) + } + + if len(allRecentPosts) != 5 { + t.Errorf("Expected 5 posts (all available), got %d", len(allRecentPosts)) + } +} + +func TestSince(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test posts 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 news since timestamp: %v", err) + } + + // Should get posts from 2 days ago, 1 day ago, and 1 hour ago + expectedCount := 3 + if len(recentPosts) != expectedCount { + t.Errorf("Expected %d posts since 3 days ago, got %d", expectedCount, len(recentPosts)) + } + + // Verify all posts are since the timestamp + for _, post := range recentPosts { + if post.Posted < threeDaysAgo { + t.Errorf("Post with timestamp %d is before the 'since' timestamp %d", post.Posted, threeDaysAgo) + } + } + + // Test with future timestamp (should return no posts) + futurePosts, err := Since(db, time.Now().AddDate(0, 0, 1).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 TestBetween(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Test posts between 6 days ago and 1 day ago + start := time.Now().AddDate(0, 0, -6).Unix() + end := time.Now().AddDate(0, 0, -1).Unix() + + betweenPosts, err := Between(db, start, end) + if err != nil { + t.Fatalf("Failed to get news between timestamps: %v", err) + } + + // Should get posts from 5 days ago, 2 days ago, and 1 day ago + expectedCount := 3 + if len(betweenPosts) != expectedCount { + t.Errorf("Expected %d posts between timestamps, got %d", expectedCount, len(betweenPosts)) + } + + // Verify all posts are within the range + for _, post := range betweenPosts { + if post.Posted < start || post.Posted > end { + t.Errorf("Post with timestamp %d is outside range [%d, %d]", post.Posted, start, end) + } + } + + // Test with narrow range (should return fewer posts) + narrowStart := time.Now().AddDate(0, 0, -2).Unix() + narrowEnd := time.Now().AddDate(0, 0, -1).Unix() + + narrowPosts, err := Between(db, narrowStart, narrowEnd) + if err != nil { + t.Fatalf("Failed to get news in narrow range: %v", err) + } + + if len(narrowPosts) != 2 { // 2 days ago and 1 day ago + t.Errorf("Expected 2 posts in narrow range, got %d", len(narrowPosts)) + } +} + +func TestBuilder(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create new news using builder + testTime := time.Now() + news, err := NewBuilder(db). + WithAuthor(5). + WithContent("Test news content from builder"). + WithPostedTime(testTime). + Create() + + if err != nil { + t.Fatalf("Failed to create news with builder: %v", err) + } + + if news.ID == 0 { + t.Error("Expected non-zero ID after creation") + } + if news.Author != 5 { + t.Errorf("Expected author 5, got %d", news.Author) + } + if news.Content != "Test news content from builder" { + t.Errorf("Expected specific content, got '%s'", news.Content) + } + if news.Posted != testTime.Unix() { + t.Errorf("Expected posted time %d, got %d", testTime.Unix(), news.Posted) + } + + // Verify it was saved to database + foundNews, err := Find(db, news.ID) + if err != nil { + t.Fatalf("Failed to find created news: %v", err) + } + + if foundNews.Content != "Test news content from builder" { + t.Errorf("Created news not found in database") + } + + // Test builder with default timestamp + defaultNews, err := NewBuilder(db). + WithAuthor(1). + WithContent("News with default timestamp"). + Create() + + if err != nil { + t.Fatalf("Failed to create news with default timestamp: %v", err) + } + + // Should have recent timestamp (within last minute) + if time.Since(defaultNews.PostedTime()) > time.Minute { + t.Error("Expected default timestamp to be recent") + } +} + +func TestSave(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + news, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find news: %v", err) + } + + // Modify news + news.Author = 999 + news.Content = "Updated content" + news.Posted = time.Now().Unix() + + // Save changes + err = news.Save() + if err != nil { + t.Fatalf("Failed to save news: %v", err) + } + + // Verify changes were saved + updatedNews, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find updated news: %v", err) + } + + if updatedNews.Author != 999 { + t.Errorf("Expected updated author 999, got %d", updatedNews.Author) + } + if updatedNews.Content != "Updated content" { + t.Errorf("Expected updated content, got '%s'", updatedNews.Content) + } +} + +func TestDelete(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + news, err := Find(db, 1) + if err != nil { + t.Fatalf("Failed to find news: %v", err) + } + + // Delete news + err = news.Delete() + if err != nil { + t.Fatalf("Failed to delete news: %v", err) + } + + // Verify news was deleted + _, err = Find(db, 1) + if err == nil { + t.Error("Expected error when finding deleted news") + } +} + +func TestUtilityMethods(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + news, _ := Find(db, 1) + + // Test PostedTime + postedTime := news.PostedTime() + if postedTime.IsZero() { + t.Error("Expected non-zero posted time") + } + + // Test IsRecent (should be false for old posts initially) + if news.IsRecent() { + t.Error("Expected old news post not to be recent") + } + + // Test SetPostedTime + newTime := time.Now().Add(-2 * time.Hour) + news.SetPostedTime(newTime) + if news.Posted != newTime.Unix() { + t.Errorf("Expected posted timestamp %d, got %d", newTime.Unix(), news.Posted) + } + + // Test IsRecent (should be true after setting to 2 hours ago) + if !news.IsRecent() { + t.Error("Expected news post from 2 hours ago to be recent") + } + + // Create recent post + recentNews, _ := NewBuilder(db). + WithAuthor(1). + WithContent("Recent post"). + Create() + + if !recentNews.IsRecent() { + t.Error("Expected newly created post to be recent") + } + + // Test Age + age := news.Age() + if age < 0 { + t.Error("Expected positive age") + } + + // Test IsAuthor + if !news.IsAuthor(news.Author) { + t.Error("Expected IsAuthor to return true for correct author") + } + if news.IsAuthor(999) { + t.Error("Expected IsAuthor to return false for incorrect author") + } + + // Test Preview + longContent := "This is a very long content that should be truncated when preview is called" + news.Content = longContent + + preview := news.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 := news.Preview(100) // Longer than content + if shortPreview != longContent { + t.Error("Expected short content to not be truncated") + } + + // Test WordCount + news.Content = "This is a test with five words" + wordCount := news.WordCount() + if wordCount != 7 { + t.Errorf("Expected 7 words, got %d", wordCount) + } + + news.Content = "" + emptyWordCount := news.WordCount() + if emptyWordCount != 0 { + t.Errorf("Expected 0 words for empty content, got %d", emptyWordCount) + } + + news.Content = "OneWord" + oneWordCount := news.WordCount() + if oneWordCount != 1 { + t.Errorf("Expected 1 word, got %d", oneWordCount) + } +} \ No newline at end of file