From 54d14ea78106b17c2b4cf7768cf12afb4b47c6f1 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Wed, 10 Dec 2025 20:36:19 +0000 Subject: [PATCH 1/3] Add timestamps for created and updated and track these in view page and API --- api/README.md | 12 ++- internal/app/controller/apiv1/create_test.go | 8 ++ internal/app/controller/apiv1/data.go | 32 +++++--- internal/app/controller/apiv1/update_test.go | 8 ++ internal/app/controller/web/view_test.go | 16 ++++ internal/app/model/journal.go | 46 ++++++++--- internal/app/model/journal_test.go | 84 ++++++++++++++++++++ internal/app/model/migration.go | 35 ++++++++ journal.go | 4 + journal_test.go | 33 +++++--- test/mocks/database/database.go | 8 ++ web/static/openapi.yml | 8 ++ web/templates/view.html.tmpl | 10 +++ 13 files changed, 270 insertions(+), 34 deletions(-) diff --git a/api/README.md b/api/README.md index 0a5ebb8..2f65266 100644 --- a/api/README.md +++ b/api/README.md @@ -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" } ] } @@ -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" } ``` diff --git a/internal/app/controller/apiv1/create_test.go b/internal/app/controller/apiv1/create_test.go index a246f01..ccae62d 100644 --- a/internal/app/controller/apiv1/create_test.go +++ b/internal/app/controller/apiv1/create_test.go @@ -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") + } } diff --git a/internal/app/controller/apiv1/data.go b/internal/app/controller/apiv1/data.go index b8c0f34..db3d91c 100644 --- a/internal/app/controller/apiv1/data.go +++ b/internal/app/controller/apiv1/data.go @@ -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 { diff --git a/internal/app/controller/apiv1/update_test.go b/internal/app/controller/apiv1/update_test.go index 5da2fb5..0f6446f 100644 --- a/internal/app/controller/apiv1/update_test.go +++ b/internal/app/controller/apiv1/update_test.go @@ -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") + } } diff --git a/internal/app/controller/web/view_test.go b/internal/app/controller/web/view_test.go index 299dc4a..3151099 100644 --- a/internal/app/controller/web/view_test.go +++ b/internal/app/controller/web/view_test.go @@ -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") + } } diff --git a/internal/app/model/journal.go b/internal/app/model/journal.go index a13fb33..8e3136f 100644 --- a/internal/app/model/journal.go +++ b/internal/app/model/journal.go @@ -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 @@ -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 == "" { @@ -121,7 +139,9 @@ func (js *Journals) CreateTable() error { "`slug` VARCHAR(255) NOT NULL, " + "`title` VARCHAR(255) NOT NULL, " + "`date` DATE NOT NULL, " + - "`content` TEXT NOT NULL" + + "`content` TEXT NOT NULL, " + + "`created_at` DATETIME DEFAULT NULL, " + + "`updated_at` DATETIME DEFAULT NULL" + ")") return err @@ -231,11 +251,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 @@ -252,7 +280,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) } diff --git a/internal/app/model/journal_test.go b/internal/app/model/journal_test.go index 11274d9..ee194d4 100644 --- a/internal/app/model/journal_test.go +++ b/internal/app/model/journal_test.go @@ -2,6 +2,7 @@ package model import ( "testing" + "time" "github.com/jamiefdhurst/journal/internal/app" pkgDb "github.com/jamiefdhurst/journal/pkg/database" @@ -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 +} diff --git a/internal/app/model/migration.go b/internal/app/model/migration.go index 95ed259..1d6c1b6 100644 --- a/internal/app/model/migration.go +++ b/internal/app/model/migration.go @@ -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 +} diff --git a/journal.go b/journal.go index 7afac21..14872b7 100644 --- a/journal.go +++ b/journal.go @@ -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() diff --git a/journal_test.go b/journal_test.go index 93c8039..fbe5e67 100644 --- a/journal_test.go +++ b/journal_test.go @@ -184,11 +184,14 @@ func TestApiV1Create(t *testing.T) { defer res.Body.Close() body, _ := io.ReadAll(res.Body) - expected := `{"id":4,"slug":"test-4","title":"Test 4","date":"2018-06-01T00:00:00Z","content":"

Test 4!

"}` + bodyStr := string(body[:]) - // Use contains to get rid of any extra whitespace that we can discount - if !strings.Contains(string(body[:]), expected) { - t.Errorf("Expected:\n\t%s\nGot:\n\t%s", expected, string(body[:])) + // Check for expected fields + expectedFields := []string{`"id":4`, `"slug":"test-4"`, `"title":"Test 4"`, `"date":"2018-06-01T00:00:00Z"`, `"content":"

Test 4!

"`, `"created_at"`, `"updated_at"`} + for _, field := range expectedFields { + if !strings.Contains(bodyStr, field) { + t.Errorf("Expected response to contain %s\nGot:\n\t%s", field, bodyStr) + } } } @@ -255,11 +258,14 @@ func TestApiV1Create_RepeatTitles(t *testing.T) { } defer res.Body.Close() body, _ := io.ReadAll(res.Body) - expected := `{"url":"/api/v1/post/repeated-1","title":"Repeated","date":"2019-02-01T00:00:00Z","content":"

Repeated content test again!

"}` + bodyStr := string(body[:]) - // Use contains to get rid of any extra whitespace that we can discount - if !strings.Contains(string(body[:]), expected) { - t.Errorf("Expected:\n\t%s\nGot:\n\t%s", expected, string(body[:])) + // Check for expected fields + expectedFields := []string{`"url":"/api/v1/post/repeated-1"`, `"title":"Repeated"`, `"date":"2019-02-01T00:00:00Z"`, `"content":"

Repeated content test again!

"`, `"created_at"`, `"updated_at"`} + for _, field := range expectedFields { + if !strings.Contains(bodyStr, field) { + t.Errorf("Expected response to contain %s\nGot:\n\t%s", field, bodyStr) + } } } @@ -280,11 +286,14 @@ func TestApiV1Update(t *testing.T) { defer res.Body.Close() body, _ := io.ReadAll(res.Body) - expected := `{"id":1,"slug":"test","title":"A different title","date":"2018-01-01T00:00:00Z","content":"

Test!

"}` + bodyStr := string(body[:]) - // Use contains to get rid of any extra whitespace that we can discount - if !strings.Contains(string(body[:]), expected) { - t.Errorf("Expected:\n\t%s\nGot:\n\t%s", expected, string(body[:])) + // Check for expected fields + expectedFields := []string{`"id":1`, `"slug":"test"`, `"title":"A different title"`, `"date":"2018-01-01T00:00:00Z"`, `"content":"

Test!

"`, `"updated_at"`} + for _, field := range expectedFields { + if !strings.Contains(bodyStr, field) { + t.Errorf("Expected response to contain %s\nGot:\n\t%s", field, bodyStr) + } } } diff --git a/test/mocks/database/database.go b/test/mocks/database/database.go index 9e0667e..b8621cc 100644 --- a/test/mocks/database/database.go +++ b/test/mocks/database/database.go @@ -3,6 +3,7 @@ package database import ( "database/sql" "errors" + "time" "github.com/jamiefdhurst/journal/pkg/database/rows" ) @@ -51,12 +52,17 @@ func (m *MockJournal_MultipleRows) Scan(dest ...interface{}) error { *dest[2].(*string) = "Title" *dest[3].(*string) = "2018-02-01" *dest[4].(*string) = "Content" + // CreatedAt and UpdatedAt are nil for mock data (simulating old records) + *dest[5].(**time.Time) = nil + *dest[6].(**time.Time) = nil } else if m.RowNumber == 2 { *dest[0].(*int) = 2 *dest[1].(*string) = "slug-2" *dest[2].(*string) = "Title 2" *dest[3].(*string) = "2018-03-01" *dest[4].(*string) = "Content 2" + *dest[5].(**time.Time) = nil + *dest[6].(**time.Time) = nil } return nil } @@ -84,6 +90,8 @@ func (m *MockJournal_SingleRow) Scan(dest ...interface{}) error { *dest[2].(*string) = "Title" *dest[3].(*string) = "2018-02-01" *dest[4].(*string) = "Content" + *dest[5].(**time.Time) = nil + *dest[6].(**time.Time) = nil } return nil } diff --git a/web/static/openapi.yml b/web/static/openapi.yml index aa8cb12..fb244dd 100644 --- a/web/static/openapi.yml +++ b/web/static/openapi.yml @@ -118,6 +118,14 @@ components: content: type: string example: 'Some post content.' + created_at: + type: string + format: date-time + example: '2018-06-21T09:12:00Z' + updated_at: + type: string + format: date-time + example: '2018-06-21T09:12:00Z' Posts: required: - links diff --git a/web/templates/view.html.tmpl b/web/templates/view.html.tmpl index 25afe27..c3720e9 100644 --- a/web/templates/view.html.tmpl +++ b/web/templates/view.html.tmpl @@ -4,6 +4,16 @@

{{.Journal.Title}}

{{.Journal.GetDate}} + {{if or .Journal.GetFormattedCreatedAt .Journal.GetFormattedUpdatedAt}} + + {{if .Journal.GetFormattedUpdatedAt}} +
Last Updated: {{.Journal.GetFormattedUpdatedAt}} + {{end}} + {{if .Journal.GetFormattedCreatedAt}} +
Created: {{.Journal.GetFormattedCreatedAt}} + {{end}} +
+ {{end}}

{{.Journal.GetHTML}} From 41533a9f7a5513319d8b4db74044cf24e905a8d8 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Wed, 10 Dec 2025 20:39:13 +0000 Subject: [PATCH 2/3] Ensure migration creates the new created_at/updated_at fields --- internal/app/model/journal.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/app/model/journal.go b/internal/app/model/journal.go index 8e3136f..cbeedda 100644 --- a/internal/app/model/journal.go +++ b/internal/app/model/journal.go @@ -24,7 +24,7 @@ type Journal struct { Slug string `json:"slug"` Title string `json:"title"` Date string `json:"date"` - Content string `json:"content"` // Now stores markdown content + Content string `json:"content"` // Now stores markdown content CreatedAt *time.Time `json:"created_at"` // Automatically managed UpdatedAt *time.Time `json:"updated_at"` // Automatically managed } @@ -139,9 +139,7 @@ func (js *Journals) CreateTable() error { "`slug` VARCHAR(255) NOT NULL, " + "`title` VARCHAR(255) NOT NULL, " + "`date` DATE NOT NULL, " + - "`content` TEXT NOT NULL, " + - "`created_at` DATETIME DEFAULT NULL, " + - "`updated_at` DATETIME DEFAULT NULL" + + "`content` TEXT NOT NULL" + ")") return err From 70b953970429488096b04acb4105627debfb44d6 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Wed, 10 Dec 2025 20:44:27 +0000 Subject: [PATCH 3/3] Ensure migrations run as part of the full end-to-end testing --- journal_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/journal_test.go b/journal_test.go index fbe5e67..479e4a0 100644 --- a/journal_test.go +++ b/journal_test.go @@ -41,11 +41,15 @@ func fixtures(t *testing.T) { container.Db = db js := model.Journals{Container: container} + ms := model.Migrations{Container: container} vs := model.Visits{Container: container} db.Exec("DROP TABLE journal") + db.Exec("DROP TABLE migration") db.Exec("DROP TABLE visit") js.CreateTable() + ms.CreateTable() vs.CreateTable() + ms.MigrateAddTimestamps() // Set up data db.Exec("INSERT INTO journal (slug, title, content, date) VALUES (?, ?, ?, ?)", "test", "Test", "

Test!

", "2018-01-01")