Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ information on the total posts, pages and posts per page.
{
"url": "/api/v1/post/example-post",
"title": "An Example Post",
"date": "2018-05-18T12:53:22Z",
"content": "TEST"
"date": "2018-05-18T00:00:00Z",
"content": "TEST",
"created_at": "2018-05-18T15:16:17Z",
"updated_at": "2018-05-18T15:16:17Z"
}
]
}
Expand All @@ -77,8 +79,10 @@ Contains the single post.
{
"url": "/api/v1/post/example-post",
"title": "An Example Post",
"date": "2018-05-18T12:53:22Z",
"content": "TEST"
"date": "2018-05-18T00:00:00Z",
"content": "TEST",
"created_at": "2018-05-18T15:16:17Z",
"updated_at": "2018-05-18T15:16:17Z"
}
```

Expand Down
8 changes: 8 additions & 0 deletions internal/app/controller/apiv1/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,12 @@ func TestCreate_Run(t *testing.T) {
if response.StatusCode != 201 || !strings.Contains(response.Content, "Something New") {
t.Error("Expected new title to be within content")
}

// Test that timestamp fields are present in response
if !strings.Contains(response.Content, "created_at") {
t.Error("Expected created_at field to be present in JSON response")
}
if !strings.Contains(response.Content, "updated_at") {
t.Error("Expected updated_at field to be present in JSON response")
}
}
32 changes: 23 additions & 9 deletions internal/app/controller/apiv1/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,33 @@ type journalFromJSON struct {
}

type journalToJSON struct {
URL string `json:"url"`
Title string `json:"title"`
Date string `json:"date"`
Content string `json:"content"`
URL string `json:"url"`
Title string `json:"title"`
Date string `json:"date"`
Content string `json:"content"`
CreatedAt *string `json:"created_at,omitempty"`
UpdatedAt *string `json:"updated_at,omitempty"`
}

func MapJournalToJSON(journal model.Journal) journalToJSON {
return journalToJSON{
"/api/v1/post/" + journal.Slug,
journal.Title,
journal.Date,
journal.Content,
result := journalToJSON{
URL: "/api/v1/post/" + journal.Slug,
Title: journal.Title,
Date: journal.Date,
Content: journal.Content,
}

// Format timestamps in ISO 8601 format if they exist
if journal.CreatedAt != nil {
createdAtStr := journal.CreatedAt.Format("2006-01-02T15:04:05Z07:00")
result.CreatedAt = &createdAtStr
}
if journal.UpdatedAt != nil {
updatedAtStr := journal.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")
result.UpdatedAt = &updatedAtStr
}

return result
}

func MapJournalsToJSON(journals []model.Journal) []journalToJSON {
Expand Down
8 changes: 8 additions & 0 deletions internal/app/controller/apiv1/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,12 @@ func TestUpdate_Run(t *testing.T) {
if response.StatusCode != 200 || !strings.Contains(response.Content, "Something New") {
t.Error("Expected new title to be within content")
}

// Test that timestamp fields are present in response
if !strings.Contains(response.Content, "created_at") {
t.Error("Expected created_at field to be present in JSON response")
}
if !strings.Contains(response.Content, "updated_at") {
t.Error("Expected updated_at field to be present in JSON response")
}
}
16 changes: 16 additions & 0 deletions internal/app/controller/web/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,20 @@ func TestView_Run(t *testing.T) {
if !strings.Contains(response.Content, ">Previous<") || !strings.Contains(response.Content, ">Next<") {
t.Error("Expected previous and next links to be shown in page")
}

// Test that timestamp metadata section is NOT displayed when timestamps are nil
response.Reset()
request, _ = http.NewRequest("GET", "/slug", strings.NewReader(""))
// Reset database to single mode
db = &database.MockSqlite{}
container.Db = db
db.Rows = &database.MockJournal_SingleRow{}
controller.Init(container, []string{"", "slug"}, request)
controller.Run(response, request)
if strings.Contains(response.Content, "class=\"metadata\"") {
t.Error("Expected metadata section to NOT be displayed when timestamps are nil")
}
if strings.Contains(response.Content, "Created:") || strings.Contains(response.Content, "Last updated:") {
t.Error("Expected timestamp labels to NOT be displayed when timestamps are nil")
}
}
42 changes: 34 additions & 8 deletions internal/app/model/journal.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ const journalTable = "journal"

// Journal model
type Journal struct {
ID int `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Date string `json:"date"`
Content string `json:"content"` // Now stores markdown content
ID int `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Date string `json:"date"`
Content string `json:"content"` // Now stores markdown content
CreatedAt *time.Time `json:"created_at"` // Automatically managed
UpdatedAt *time.Time `json:"updated_at"` // Automatically managed
}

// GetHTML converts the Markdown content to HTML for display
Expand Down Expand Up @@ -52,6 +54,22 @@ func (j Journal) GetEditableDate() string {
return re.FindString(j.Date)
}

// GetFormattedCreatedAt returns the formatted created timestamp
func (j Journal) GetFormattedCreatedAt() string {
if j.CreatedAt == nil {
return ""
}
return j.CreatedAt.Format("January 2, 2006 at 15:04")
}

// GetFormattedUpdatedAt returns the formatted updated timestamp
func (j Journal) GetFormattedUpdatedAt() string {
if j.UpdatedAt == nil {
return ""
}
return j.UpdatedAt.Format("January 2, 2006 at 15:04")
}

// GetHTMLExcerpt returns a small extract of the entry rendered as HTML
func (j Journal) GetHTMLExcerpt(maxWords int) string {
if j.Content == "" {
Expand Down Expand Up @@ -231,11 +249,19 @@ func (js *Journals) Save(j Journal) Journal {
j.Slug = j.Slug + "-post"
}

// Manage timestamps
now := time.Now().UTC()

if j.ID == 0 {
// On insert, set both created_at and updated_at
j.CreatedAt = &now
j.UpdatedAt = &now
j.Slug = js.EnsureUniqueSlug(j.Slug, 0)
res, _ = js.Container.Db.Exec("INSERT INTO `"+journalTable+"` (`slug`, `title`, `date`, `content`) VALUES(?,?,?,?)", j.Slug, j.Title, j.Date, j.Content)
res, _ = js.Container.Db.Exec("INSERT INTO `"+journalTable+"` (`slug`, `title`, `date`, `content`, `created_at`, `updated_at`) VALUES(?,?,?,?,?,?)", j.Slug, j.Title, j.Date, j.Content, j.CreatedAt, j.UpdatedAt)
} else {
res, _ = js.Container.Db.Exec("UPDATE `"+journalTable+"` SET `slug` = ?, `title` = ?, `date` = ?, `content` = ? WHERE `id` = ?", j.Slug, j.Title, j.Date, j.Content, strconv.Itoa(j.ID))
// On update, only update updated_at
j.UpdatedAt = &now
res, _ = js.Container.Db.Exec("UPDATE `"+journalTable+"` SET `slug` = ?, `title` = ?, `date` = ?, `content` = ?, `updated_at` = ? WHERE `id` = ?", j.Slug, j.Title, j.Date, j.Content, j.UpdatedAt, strconv.Itoa(j.ID))
}

// Store insert ID
Expand All @@ -252,7 +278,7 @@ func (js Journals) loadFromRows(rows rows.Rows) []Journal {
journals := []Journal{}
for rows.Next() {
j := Journal{}
rows.Scan(&j.ID, &j.Slug, &j.Title, &j.Date, &j.Content)
rows.Scan(&j.ID, &j.Slug, &j.Title, &j.Date, &j.Content, &j.CreatedAt, &j.UpdatedAt)
journals = append(journals, j)
}

Expand Down
84 changes: 84 additions & 0 deletions internal/app/model/journal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package model

import (
"testing"
"time"

"github.com/jamiefdhurst/journal/internal/app"
pkgDb "github.com/jamiefdhurst/journal/pkg/database"
Expand Down Expand Up @@ -365,3 +366,86 @@ func TestSlugify(t *testing.T) {
}
}
}

func TestJournal_GetFormattedCreatedAt(t *testing.T) {
// Test with nil timestamp
j := Journal{}
actual := j.GetFormattedCreatedAt()
if actual != "" {
t.Errorf("Expected empty string for nil timestamp, got '%s'", actual)
}

// Test with valid timestamp
testTime := time.Date(2025, 1, 10, 15, 45, 30, 0, time.UTC)
j.CreatedAt = &testTime
actual = j.GetFormattedCreatedAt()
expected := "January 10, 2025 at 15:45"
if actual != expected {
t.Errorf("Expected GetFormattedCreatedAt() to produce result of '%s', got '%s'", expected, actual)
}
}

func TestJournal_GetFormattedUpdatedAt(t *testing.T) {
// Test with nil timestamp
j := Journal{}
actual := j.GetFormattedUpdatedAt()
if actual != "" {
t.Errorf("Expected empty string for nil timestamp, got '%s'", actual)
}

// Test with valid timestamp
testTime := time.Date(2025, 1, 10, 15, 45, 30, 0, time.UTC)
j.UpdatedAt = &testTime
actual = j.GetFormattedUpdatedAt()
expected := "January 10, 2025 at 15:45"
if actual != expected {
t.Errorf("Expected GetFormattedUpdatedAt() to produce result of '%s', got '%s'", expected, actual)
}
}

func TestJournals_Save_Timestamps(t *testing.T) {
db := &database.MockSqlite{Result: &database.MockResult{}}
db.Rows = &database.MockRowsEmpty{}
container := &app.Container{Db: db}
js := Journals{Container: container}

// Test new Journal gets timestamps set
beforeCreate := time.Now().UTC()
journal := js.Save(Journal{ID: 0, Title: "Testing", Date: "2025-01-10", Content: "Test content"})
afterCreate := time.Now().UTC()

if journal.CreatedAt == nil {
t.Error("Expected CreatedAt to be set on new journal")
}
if journal.UpdatedAt == nil {
t.Error("Expected UpdatedAt to be set on new journal")
}

// Verify timestamps are within reasonable range
if journal.CreatedAt.Before(beforeCreate) || journal.CreatedAt.After(afterCreate) {
t.Error("CreatedAt timestamp is outside expected time range")
}
if journal.UpdatedAt.Before(beforeCreate) || journal.UpdatedAt.After(afterCreate) {
t.Error("UpdatedAt timestamp is outside expected time range")
}

// Test updating Journal only updates UpdatedAt
time.Sleep(10 * time.Millisecond) // Small delay to ensure different timestamp

beforeUpdate := time.Now().UTC()
journal.Title = "Updated Title"
updatedJournal := js.Save(journal)
afterUpdate := time.Now().UTC()

if updatedJournal.UpdatedAt == nil {
t.Error("Expected UpdatedAt to be set on updated journal")
}

// Verify UpdatedAt changed but CreatedAt didn't
if updatedJournal.UpdatedAt.Before(beforeUpdate) || updatedJournal.UpdatedAt.After(afterUpdate) {
t.Error("UpdatedAt timestamp is outside expected time range after update")
}

// Note: In the mock, CreatedAt won't be preserved since we're not actually reading from DB,
// but in real usage the query would only update updated_at
}
35 changes: 35 additions & 0 deletions internal/app/model/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,38 @@ func (ms *Migrations) MigrateRandomSlugs() error {

return nil
}

// MigrateAddTimestamps adds created_at and updated_at columns to the journal table
func (ms *Migrations) MigrateAddTimestamps() error {
const migrationName = "add_timestamps"

// Skip if already migrated
if ms.HasMigrationRun(migrationName) {
log.Println("Add timestamps migration already applied. Skipping...")
return nil
}

log.Println("Running add timestamps migration...")

// Add created_at column
_, err := ms.Container.Db.Exec("ALTER TABLE `" + journalTable + "` ADD COLUMN `created_at` DATETIME DEFAULT NULL")
if err != nil {
return fmt.Errorf("failed to add created_at column: %w", err)
}

// Add updated_at column
_, err = ms.Container.Db.Exec("ALTER TABLE `" + journalTable + "` ADD COLUMN `updated_at` DATETIME DEFAULT NULL")
if err != nil {
return fmt.Errorf("failed to add updated_at column: %w", err)
}

log.Println("Successfully added created_at and updated_at columns to journal table.")

// Record migration as completed
err = ms.RecordMigration(migrationName)
if err != nil {
return fmt.Errorf("migration completed but failed to record status: %w", err)
}

return nil
}
4 changes: 4 additions & 0 deletions journal.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ func loadDatabase() func() {
log.Printf("Error during random slug migration: %s\n", err)
log.Panicln(err)
}
if err := ms.MigrateAddTimestamps(); err != nil {
log.Printf("Error during add timestamps migration: %s\n", err)
log.Panicln(err)
}

return func() {
container.Db.Close()
Expand Down
Loading