@@ -28,7 +28,6 @@

GitHub

Journal v{{.Container.Version}}

- {{if ne .Container.Configuration.GoogleAnalyticsCode ""}} diff --git a/web/templates/stats.html.tmpl b/web/templates/stats.html.tmpl new file mode 100644 index 0000000..d97a24a --- /dev/null +++ b/web/templates/stats.html.tmpl @@ -0,0 +1,41 @@ +{{define "title"}}Stats - {{end}} +{{define "content"}} + +

Stats

+ +
+

Posts

+
+
Total Posts
+
{{.PostCount}}
+ +
First Post Date
+
{{.FirstPostDate}}
+
+ +

Configuration

+
+
Title
+
{{.Container.Configuration.Title}}{{if .TitleSet}}{{else}} (Default){{end}}
+ +
Description
+
{{.Container.Configuration.Description}}{{if .DescriptionSet}}{{else}} (Default){{end}}
+ +
Theme
+
{{.Container.Configuration.Theme}}
+ +
Posts Per Page
+
{{.ArticlesPerPage}}
+ +
Google Analytics
+
{{if .GACodeSet}}Enabled{{else}}Disabled{{end}}
+ +
Create Posts
+
{{if .CreateEnabled}}Enabled{{else}}Disabled{{end}}
+ +
Edit Posts
+
{{if .EditEnabled}}Enabled{{else}}Disabled{{end}}
+
+
+ +{{end}} \ No newline at end of file diff --git a/web/themes/default/style.css b/web/themes/default/style.css index 6919a39..4feafbd 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -462,3 +462,38 @@ fieldset p { margin: 2rem 0; text-align: right; } + +section.stats dl { + margin: 0 0 3rem; + padding: 0; +} + +section.stats dl:after { + content: ""; + display: table; + clear: both; +} + +section.stats dt, +section.stats dd { + display: block; + float: left; + margin: 0; + padding: 0.75rem 0.5rem; + box-sizing: border-box; +} + +section.stats dt { + width: 40%; + clear: left; + font-weight: bold; +} + +section.stats dd { + width: 60%; +} + +section.stats dt:nth-of-type(odd), +section.stats dd:nth-of-type(odd) { + background-color: #f7f7f7; +} From 165691a63e2b1e3faf02a79a97e991ccf698f4ec Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sat, 24 May 2025 22:00:01 +0100 Subject: [PATCH 22/52] Add API endpoint for stats --- api/README.md | 30 ++++++++++ internal/app/controller/apiv1/stats.go | 65 +++++++++++++++++++++ internal/app/controller/apiv1/stats_test.go | 59 +++++++++++++++++++ internal/app/router/router.go | 1 + journal_test.go | 33 ++++++++++- web/static/openapi.yml | 57 +++++++++++++++++- 6 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 internal/app/controller/apiv1/stats.go create mode 100644 internal/app/controller/apiv1/stats_test.go diff --git a/api/README.md b/api/README.md index cf4b143..cabbb9d 100644 --- a/api/README.md +++ b/api/README.md @@ -193,3 +193,33 @@ When updating the post, the slug remains constant, even when the title changes. * `400` - Incorrect parameters supplied - at least one or more of the date, title and content must be provided. * `404` - Post with provided slug could not be found. + +--- + +### Stats + +**Method/URL:** `GET /api/v1/stats` + +**Successful Response:** `200` + +Retrieve statistics and configuration information on the current installation. + +```json +{ + "posts": { + "count": 3, + "first_post_date": "Monday January 1, 2018" + }, + "configuration": { + "title": "Jamie's Journal", + "description": "A private journal containing Jamie's innermost thoughts", + "theme": "default", + "posts_per_page": 20, + "google_analytics": false, + "create_enabled": true, + "edit_enabled": true + } +} +``` + +**Error Responses:** *None* diff --git a/internal/app/controller/apiv1/stats.go b/internal/app/controller/apiv1/stats.go new file mode 100644 index 0000000..0bfbd54 --- /dev/null +++ b/internal/app/controller/apiv1/stats.go @@ -0,0 +1,65 @@ +package apiv1 + +import ( + "encoding/json" + "net/http" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" +) + +// Stats Provide statistics about the journal system +type Stats struct { + controller.Super +} + +type statsJSON struct { + Posts statsPostsJSON `json:"posts"` + Configuration statsConfigJSON `json:"configuration"` +} + +type statsPostsJSON struct { + Count int `json:"count"` + FirstPostDate string `json:"first_post_date,omitempty"` +} + +type statsConfigJSON struct { + Title string `json:"title"` + Description string `json:"description"` + Theme string `json:"theme"` + ArticlesPerPage int `json:"posts_per_page"` + GoogleAnalytics bool `json:"google_analytics"` + CreateEnabled bool `json:"create_enabled"` + EditEnabled bool `json:"edit_enabled"` +} + +// Run Stats action +func (c *Stats) Run(response http.ResponseWriter, request *http.Request) { + stats := statsJSON{} + + container := c.Super.Container().(*app.Container) + + js := model.Journals{Container: container} + allJournals := js.FetchAll() + stats.Posts.Count = len(allJournals) + + if stats.Posts.Count > 0 { + firstPost := allJournals[stats.Posts.Count-1] + stats.Posts.FirstPostDate = firstPost.GetDate() + } + + stats.Configuration.Title = container.Configuration.Title + stats.Configuration.Description = container.Configuration.Description + stats.Configuration.Theme = container.Configuration.Theme + stats.Configuration.ArticlesPerPage = container.Configuration.ArticlesPerPage + stats.Configuration.GoogleAnalytics = container.Configuration.GoogleAnalyticsCode != "" + stats.Configuration.CreateEnabled = container.Configuration.EnableCreate + stats.Configuration.EditEnabled = container.Configuration.EnableEdit + + // Send JSON response + response.Header().Add("Content-Type", "application/json") + encoder := json.NewEncoder(response) + encoder.SetEscapeHTML(false) + encoder.Encode(stats) +} diff --git a/internal/app/controller/apiv1/stats_test.go b/internal/app/controller/apiv1/stats_test.go new file mode 100644 index 0000000..a5b00a2 --- /dev/null +++ b/internal/app/controller/apiv1/stats_test.go @@ -0,0 +1,59 @@ +package apiv1 + +import ( + "net/http" + "os" + "strings" + "testing" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" +) + +func TestStats_Run(t *testing.T) { + db := &database.MockSqlite{} + configuration := app.DefaultConfiguration() + configuration.ArticlesPerPage = 25 // Custom setting + configuration.GoogleAnalyticsCode = "UA-123456" // Custom GA code + container := &app.Container{Configuration: configuration, Db: db} + response := &controller.MockResponse{} + response.Reset() + controller := &Stats{} + os.Chdir(os.Getenv("GOPATH") + "/src/github.com/jamiefdhurst/journal") + + // Test with journals + db.Rows = &database.MockJournal_MultipleRows{} + request := &http.Request{Method: "GET"} + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + + if response.StatusCode != 200 { + t.Error("Expected 200 status code") + } + if response.Headers.Get("Content-Type") != "application/json" { + t.Error("Expected JSON content type") + } + + if !strings.Contains(response.Content, "count\":2,") { + t.Errorf("Expected post count to be 2, got response %s", response.Content) + } + if !strings.Contains(response.Content, "posts_per_page\":25,") { + t.Errorf("Expected articles per page to be 25, got response %s", response.Content) + } + if !strings.Contains(response.Content, "google_analytics\":true") { + t.Error("Expected Google Analytics to be enabled") + } + + // Now test with no journals + response.Reset() + db.Rows = &database.MockRowsEmpty{} + controller.Run(response, request) + + if !strings.Contains(response.Content, "count\":0}") { + t.Errorf("Expected post count to be 0, got response %s", response.Content) + } + if strings.Contains(response.Content, "first_post_date") { + t.Error("Expected first_post_date to be omitted when no posts exist") + } +} diff --git a/internal/app/router/router.go b/internal/app/router/router.go index d8d0f82..170a6ac 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -22,6 +22,7 @@ func NewRouter(app *app.Container) *pkgrouter.Router { rtr.Get("/new", &web.New{}) rtr.Post("/new", &web.New{}) rtr.Get("/random", &web.Random{}) + rtr.Get("/api/v1/stats", &apiv1.Stats{}) rtr.Get("/api/v1/post", &apiv1.List{}) rtr.Put("/api/v1/post", &apiv1.Create{}) rtr.Get("/api/v1/post/random", &apiv1.Random{}) diff --git a/journal_test.go b/journal_test.go index b70b24d..6b202c8 100644 --- a/journal_test.go +++ b/journal_test.go @@ -315,10 +315,10 @@ func TestApiV1Update_InvalidRequest(t *testing.T) { } } -func TestOpenapi(t *testing.T) { +func TestApiV1Stats(t *testing.T) { fixtures(t) - request, _ := http.NewRequest("GET", server.URL+"/openapi.yml", nil) + request, _ := http.NewRequest("GET", server.URL+"/api/v1/stats", nil) res, err := http.DefaultClient.Do(request) @@ -332,12 +332,39 @@ func TestOpenapi(t *testing.T) { defer res.Body.Close() body, _ := io.ReadAll(res.Body) - expected := "openapi: '3.0.3'" + expected := `{"posts":{"count":3,"first_post_date":"Monday January 1, 2018"},"configuration":{"title":"Jamie's Journal","description":"A private journal containing Jamie's innermost thoughts","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true}}` + + // 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[:])) } } +func TestOpenapi(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("GET", server.URL+"/openapi.yml", nil) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + expected := []string{"openapi: '3.0.3'", "/api/v1/post:", "/api/v1/post/{slug}:", "/api/v1/post/random:", "/api/v1/stats:"} + for _, e := range expected { + if !strings.Contains(string(body[:]), e) { + t.Errorf("Expected:\n\t%s\nGot:\n\t%s", e, string(body[:])) + } + } +} + func TestWebStats(t *testing.T) { fixtures(t) diff --git a/web/static/openapi.yml b/web/static/openapi.yml index ee93e1d..c79d176 100644 --- a/web/static/openapi.yml +++ b/web/static/openapi.yml @@ -85,6 +85,16 @@ paths: description: Incorrect parameters supplied - the date, title and content must be provided. '404': description: Post with provided slug could not be found. + /api/v1/stats: + get: + description: Retrieve statistics about the journal system + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Stats' components: schemas: Post: @@ -171,4 +181,49 @@ components: content: type: string example: 'Some post content.' - + Stats: + required: + - posts + - configuration + type: object + properties: + posts: + type: object + required: + - count + properties: + count: + type: integer + example: 42 + first_post_date: + type: string + example: 'Monday January 1, 2018' + configuration: + type: object + required: + - title + - description + - theme + - posts_per_page + - google_analytics + - create_enabled + - edit_enabled + properties: + title: + type: string + example: "Jamie's Journal" + description: + type: string + example: "A private journal containing Jamie's innermost thoughts" + theme: + type: string + example: "default" + posts_per_page: + type: integer + example: 20 + google_analytics: + type: boolean + create_enabled: + type: boolean + edit_enabled: + type: boolean From 580bd3c1527877314a857e30c2b7421818ae71dc Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 25 May 2025 10:00:14 +0100 Subject: [PATCH 23/52] Rename migrations table to be singular, same as journal --- .../app/model/{migrations.go => migration.go} | 44 +++++++++---------- .../{migrations_test.go => migration_test.go} | 0 2 files changed, 22 insertions(+), 22 deletions(-) rename internal/app/model/{migrations.go => migration.go} (75%) rename internal/app/model/{migrations_test.go => migration_test.go} (100%) diff --git a/internal/app/model/migrations.go b/internal/app/model/migration.go similarity index 75% rename from internal/app/model/migrations.go rename to internal/app/model/migration.go index 6bf447d..95ed259 100644 --- a/internal/app/model/migrations.go +++ b/internal/app/model/migration.go @@ -9,7 +9,7 @@ import ( "github.com/jamiefdhurst/journal/pkg/database/rows" ) -const migrationsTable = "migrations" +const migrationTable = "migration" // Migration stores a record of migrations that have been applied type Migration struct { @@ -24,8 +24,8 @@ type Migrations struct { } // CreateTable initializes the migrations table -func (m *Migrations) CreateTable() error { - _, err := m.Container.Db.Exec("CREATE TABLE IF NOT EXISTS `" + migrationsTable + "` (" + +func (ms *Migrations) CreateTable() error { + _, err := ms.Container.Db.Exec("CREATE TABLE IF NOT EXISTS `" + migrationTable + "` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT, " + "`name` VARCHAR(255) NOT NULL, " + "`applied` BOOLEAN NOT NULL DEFAULT 0" + @@ -35,34 +35,34 @@ func (m *Migrations) CreateTable() error { } // HasMigrationRun checks if a specific migration has been applied -func (m *Migrations) HasMigrationRun(name string) bool { - rows, err := m.Container.Db.Query("SELECT * FROM `"+migrationsTable+"` WHERE `name` = ? LIMIT 1", name) +func (ms *Migrations) HasMigrationRun(name string) bool { + rows, err := ms.Container.Db.Query("SELECT * FROM `"+migrationTable+"` WHERE `name` = ? LIMIT 1", name) if err != nil { return false } - migrations := m.loadFromRows(rows) + migrations := ms.loadFromRows(rows) return len(migrations) > 0 && migrations[0].Applied } // RecordMigration marks a migration as applied -func (m *Migrations) RecordMigration(name string) error { +func (ms *Migrations) RecordMigration(name string) error { // Check if migration exists first - rows, err := m.Container.Db.Query("SELECT * FROM `"+migrationsTable+"` WHERE `name` = ? LIMIT 1", name) + rows, err := ms.Container.Db.Query("SELECT * FROM `"+migrationTable+"` WHERE `name` = ? LIMIT 1", name) if err != nil { return err } - migrations := m.loadFromRows(rows) + migrations := ms.loadFromRows(rows) var res sql.Result if len(migrations) == 0 { // Create new migration record - res, err = m.Container.Db.Exec("INSERT INTO `"+migrationsTable+"` (`name`, `applied`) VALUES(?, ?)", name, true) + res, err = ms.Container.Db.Exec("INSERT INTO `"+migrationTable+"` (`name`, `applied`) VALUES(?, ?)", name, true) } else { // Update existing migration record - res, err = m.Container.Db.Exec("UPDATE `"+migrationsTable+"` SET `applied` = ? WHERE `id` = ?", true, migrations[0].ID) + res, err = ms.Container.Db.Exec("UPDATE `"+migrationTable+"` SET `applied` = ? WHERE `id` = ?", true, migrations[0].ID) } if err != nil { @@ -73,7 +73,7 @@ func (m *Migrations) RecordMigration(name string) error { return err } -func (m *Migrations) loadFromRows(rows rows.Rows) []Migration { +func (ms *Migrations) loadFromRows(rows rows.Rows) []Migration { defer rows.Close() migrations := []Migration{} for rows.Next() { @@ -86,11 +86,11 @@ func (m *Migrations) loadFromRows(rows rows.Rows) []Migration { } // MigrateHTMLToMarkdown converts all journal entries from HTML to Markdown -func (m *Migrations) MigrateHTMLToMarkdown() error { +func (ms *Migrations) MigrateHTMLToMarkdown() error { const migrationName = "html_to_markdown" // Skip if already migrated - if m.HasMigrationRun(migrationName) { + if ms.HasMigrationRun(migrationName) { log.Println("HTML to Markdown migration already applied. Skipping...") return nil } @@ -98,7 +98,7 @@ func (m *Migrations) MigrateHTMLToMarkdown() error { log.Println("Running HTML to Markdown migration...") // Get all journal entries - js := Journals{Container: m.Container} + js := Journals{Container: ms.Container} journalEntries := js.FetchAll() log.Printf("Found %d journal entries to migrate\n", len(journalEntries)) @@ -106,7 +106,7 @@ func (m *Migrations) MigrateHTMLToMarkdown() error { count := 0 for _, journal := range journalEntries { // Convert HTML content to Markdown - markdownContent := m.Container.MarkdownProcessor.FromHTML(journal.Content) + markdownContent := ms.Container.MarkdownProcessor.FromHTML(journal.Content) journal.Content = markdownContent // Save the entry with the new markdown content @@ -119,7 +119,7 @@ func (m *Migrations) MigrateHTMLToMarkdown() error { log.Printf("Migration complete. Converted %d journal entries from HTML to Markdown.\n", count) // Record migration as completed - err := m.RecordMigration(migrationName) + err := ms.RecordMigration(migrationName) if err != nil { return fmt.Errorf("migration completed but failed to record status: %w", err) } @@ -128,11 +128,11 @@ func (m *Migrations) MigrateHTMLToMarkdown() error { } // MigrateRandomSlugs fixes any journal entries that have the "random" slug -func (m *Migrations) MigrateRandomSlugs() error { +func (ms *Migrations) MigrateRandomSlugs() error { const migrationName = "random_slug_fix" // Skip if already migrated - if m.HasMigrationRun(migrationName) { + if ms.HasMigrationRun(migrationName) { log.Println("Random slug fix migration already applied. Skipping...") return nil } @@ -140,7 +140,7 @@ func (m *Migrations) MigrateRandomSlugs() error { log.Println("Running random slug fix migration...") // Get the journal with the 'random' slug if it exists - js := Journals{Container: m.Container} + js := Journals{Container: ms.Container} randomJournal := js.FindBySlug("random") if randomJournal.ID == 0 { @@ -153,10 +153,10 @@ func (m *Migrations) MigrateRandomSlugs() error { } // Record migration as completed - err := m.RecordMigration(migrationName) + err := ms.RecordMigration(migrationName) if err != nil { return fmt.Errorf("migration completed but failed to record status: %w", err) } return nil -} \ No newline at end of file +} diff --git a/internal/app/model/migrations_test.go b/internal/app/model/migration_test.go similarity index 100% rename from internal/app/model/migrations_test.go rename to internal/app/model/migration_test.go From 2acbefe1f694ba9133f2f517adda841f5549bcaf Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 25 May 2025 10:06:51 +0100 Subject: [PATCH 24/52] Add visits table --- internal/app/model/visit.go | 32 ++++++++++++++++++++++++++++++++ internal/app/model/visit_test.go | 18 ++++++++++++++++++ journal.go | 26 ++++++++++++++------------ 3 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 internal/app/model/visit.go create mode 100644 internal/app/model/visit_test.go diff --git a/internal/app/model/visit.go b/internal/app/model/visit.go new file mode 100644 index 0000000..36c6223 --- /dev/null +++ b/internal/app/model/visit.go @@ -0,0 +1,32 @@ +package model + +import ( + "github.com/jamiefdhurst/journal/internal/app" +) + +const visitTable = "visit" + +// Visit stores a record of daily visits for a given endpoint/web address +type Visit struct { + ID int `json:"id"` + Date string `json:"date"` + URL string `json:"url"` + Hits int `json:"hits"` +} + +// Visits manages tracking API hits +type Visits struct { + Container *app.Container +} + +// CreateTable initializes the visits table +func (vs *Visits) CreateTable() error { + _, err := vs.Container.Db.Exec("CREATE TABLE IF NOT EXISTS `" + visitTable + "` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT, " + + "`date` DATE NOT NULL, " + + "`url` VARCHAR(255) NOT NULL, " + + "`hits` INTEGER UNSIGNED NOT NULL DEFAULT 0" + + ")") + + return err +} diff --git a/internal/app/model/visit_test.go b/internal/app/model/visit_test.go new file mode 100644 index 0000000..da06f00 --- /dev/null +++ b/internal/app/model/visit_test.go @@ -0,0 +1,18 @@ +package model + +import ( + "testing" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/database" +) + +func TestVisits_CreateTable(t *testing.T) { + db := &database.MockSqlite{} + container := &app.Container{Db: db} + visits := Visits{Container: container} + visits.CreateTable() + if db.Queries != 1 { + t.Error("Expected 1 query to have been run") + } +} diff --git a/journal.go b/journal.go index eef5ac9..e6fea0a 100644 --- a/journal.go +++ b/journal.go @@ -34,37 +34,39 @@ func config() app.Configuration { func loadDatabase() func() { container.Db = &database.Sqlite{} - + // Set up the markdown processor container.MarkdownProcessor = &markdown.Markdown{} - + log.Printf("Loading DB from %s...\n", container.Configuration.DatabasePath) if err := container.Db.Connect(container.Configuration.DatabasePath); err != nil { log.Printf("Database error - please verify that the %s path is available and writeable.\nError: %s\n", container.Configuration.DatabasePath, err) os.Exit(1) } - // Initialize journal table + // Create needed tables js := model.Journals{Container: container} if err := js.CreateTable(); err != nil { + log.Printf("Error creating journal table: %s\n", err) log.Panicln(err) } - - // Initialize and run migrations - migrations := model.Migrations{Container: container} - if err := migrations.CreateTable(); err != nil { + ms := model.Migrations{Container: container} + if err := ms.CreateTable(); err != nil { log.Printf("Error creating migrations table: %s\n", err) log.Panicln(err) } + vs := model.Visits{Container: container} + if err := vs.CreateTable(); err != nil { + log.Printf("Error creating visits table: %s\n", err) + log.Panicln(err) + } - // Run HTML to Markdown migration if needed - if err := migrations.MigrateHTMLToMarkdown(); err != nil { + // Run migrations + if err := ms.MigrateHTMLToMarkdown(); err != nil { log.Printf("Error during HTML to Markdown migration: %s\n", err) log.Panicln(err) } - - // Run random slug migration if needed - if err := migrations.MigrateRandomSlugs(); err != nil { + if err := ms.MigrateRandomSlugs(); err != nil { log.Printf("Error during random slug migration: %s\n", err) log.Panicln(err) } From 8af086af912179c1bab2fd3a26b2e52b4f051865 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 25 May 2025 10:41:20 +0100 Subject: [PATCH 25/52] Enable visit and API tracking --- internal/app/controller/apiv1/create_test.go | 1 + internal/app/controller/apiv1/list_test.go | 1 + internal/app/controller/apiv1/random_test.go | 1 + internal/app/controller/apiv1/single_test.go | 3 +- internal/app/controller/apiv1/stats_test.go | 3 +- internal/app/controller/apiv1/update_test.go | 3 +- .../app/controller/web/badrequest_test.go | 1 + internal/app/controller/web/edit_test.go | 3 + internal/app/controller/web/index_test.go | 1 + internal/app/controller/web/new_test.go | 1 + internal/app/controller/web/random_test.go | 1 + internal/app/controller/web/sitemap_test.go | 1 + internal/app/controller/web/stats_test.go | 1 + internal/app/controller/web/view_test.go | 1 + internal/app/model/visit.go | 35 +++++++++++ internal/app/model/visit_test.go | 59 ++++++++++++++++++- journal_test.go | 58 ++++++++++++++++++ pkg/controller/controller.go | 37 ++++++++++-- test/mocks/database/database.go | 26 ++++++++ 19 files changed, 223 insertions(+), 14 deletions(-) diff --git a/internal/app/controller/apiv1/create_test.go b/internal/app/controller/apiv1/create_test.go index eae2ea8..a246f01 100644 --- a/internal/app/controller/apiv1/create_test.go +++ b/internal/app/controller/apiv1/create_test.go @@ -18,6 +18,7 @@ func TestCreate_Run(t *testing.T) { response := controller.NewMockResponse() response.Reset() controller := &Create{} + controller.DisableTracking() // Test forbidden container.Configuration.EnableCreate = false diff --git a/internal/app/controller/apiv1/list_test.go b/internal/app/controller/apiv1/list_test.go index a0f072b..220a3e3 100644 --- a/internal/app/controller/apiv1/list_test.go +++ b/internal/app/controller/apiv1/list_test.go @@ -16,6 +16,7 @@ func TestList_Run(t *testing.T) { response := &controller.MockResponse{} response.Reset() controller := &List{} + controller.DisableTracking() // Test showing all Journals db.EnableMultiMode() diff --git a/internal/app/controller/apiv1/random_test.go b/internal/app/controller/apiv1/random_test.go index 250e960..59c0acd 100644 --- a/internal/app/controller/apiv1/random_test.go +++ b/internal/app/controller/apiv1/random_test.go @@ -15,6 +15,7 @@ func TestRandom_Run(t *testing.T) { db := &database.MockSqlite{} container := &app.Container{Db: db} random := &Random{} + random.DisableTracking() // Test with a journal found db.Rows = &database.MockJournal_SingleRow{} diff --git a/internal/app/controller/apiv1/single_test.go b/internal/app/controller/apiv1/single_test.go index 4facf20..6952f05 100644 --- a/internal/app/controller/apiv1/single_test.go +++ b/internal/app/controller/apiv1/single_test.go @@ -2,7 +2,6 @@ package apiv1 import ( "net/http" - "os" "strings" "testing" @@ -17,7 +16,7 @@ func TestSingle_Run(t *testing.T) { response := &controller.MockResponse{} response.Reset() controller := &Single{} - os.Chdir(os.Getenv("GOPATH") + "/src/github.com/jamiefdhurst/journal") + controller.DisableTracking() // Test not found/error with GET db.Rows = &database.MockRowsEmpty{} diff --git a/internal/app/controller/apiv1/stats_test.go b/internal/app/controller/apiv1/stats_test.go index a5b00a2..435a6a0 100644 --- a/internal/app/controller/apiv1/stats_test.go +++ b/internal/app/controller/apiv1/stats_test.go @@ -2,7 +2,6 @@ package apiv1 import ( "net/http" - "os" "strings" "testing" @@ -20,7 +19,7 @@ func TestStats_Run(t *testing.T) { response := &controller.MockResponse{} response.Reset() controller := &Stats{} - os.Chdir(os.Getenv("GOPATH") + "/src/github.com/jamiefdhurst/journal") + controller.DisableTracking() // Test with journals db.Rows = &database.MockJournal_MultipleRows{} diff --git a/internal/app/controller/apiv1/update_test.go b/internal/app/controller/apiv1/update_test.go index ee598a7..5da2fb5 100644 --- a/internal/app/controller/apiv1/update_test.go +++ b/internal/app/controller/apiv1/update_test.go @@ -2,7 +2,6 @@ package apiv1 import ( "net/http" - "os" "strings" "testing" @@ -17,7 +16,7 @@ func TestUpdate_Run(t *testing.T) { response := &controller.MockResponse{} response.Reset() controller := &Update{} - os.Chdir(os.Getenv("GOPATH") + "/src/github.com/jamiefdhurst/journal") + controller.DisableTracking() // Test forbidden container.Configuration.EnableEdit = false diff --git a/internal/app/controller/web/badrequest_test.go b/internal/app/controller/web/badrequest_test.go index 572b24a..7a979de 100644 --- a/internal/app/controller/web/badrequest_test.go +++ b/internal/app/controller/web/badrequest_test.go @@ -26,6 +26,7 @@ func TestError_Run(t *testing.T) { configuration := app.DefaultConfiguration() container := &app.Container{Configuration: configuration} controller := &BadRequest{} + controller.DisableTracking() request, _ := http.NewRequest("GET", "/", strings.NewReader("")) // Test header and response diff --git a/internal/app/controller/web/edit_test.go b/internal/app/controller/web/edit_test.go index 4c839d0..822a442 100644 --- a/internal/app/controller/web/edit_test.go +++ b/internal/app/controller/web/edit_test.go @@ -29,6 +29,7 @@ func TestEdit_Run(t *testing.T) { container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &Edit{} + controller.DisableTracking() // Test not found/error with GET/POST db.Rows = &database.MockRowsEmpty{} @@ -89,6 +90,7 @@ func TestEdit_Run(t *testing.T) { // Validate error cookie on redirect // We need to create a new controller with the cookie to test flash values newController := &Edit{} + newController.DisableTracking() request, _ = http.NewRequest("GET", "/", strings.NewReader("")) request.Header.Add("Cookie", response.Headers.Get("Set-Cookie")) newController.Init(container, []string{"", "0"}, request) @@ -99,6 +101,7 @@ func TestEdit_Run(t *testing.T) { response.Reset() // Create a new controller instance for this test prevController := &Edit{} + prevController.DisableTracking() // Submit a form with a missing field (date is empty) request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("title=Updated+Title&date=&content=Updated+Content")) request.Header.Add("Content-Type", "application/x-www-form-urlencoded") diff --git a/internal/app/controller/web/index_test.go b/internal/app/controller/web/index_test.go index 65d8a09..a9fe16b 100644 --- a/internal/app/controller/web/index_test.go +++ b/internal/app/controller/web/index_test.go @@ -29,6 +29,7 @@ func TestIndex_Run(t *testing.T) { container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &Index{} + controller.DisableTracking() // Test showing all Journals db.EnableMultiMode() diff --git a/internal/app/controller/web/new_test.go b/internal/app/controller/web/new_test.go index e955f05..888a1bb 100644 --- a/internal/app/controller/web/new_test.go +++ b/internal/app/controller/web/new_test.go @@ -31,6 +31,7 @@ func TestNew_Run(t *testing.T) { container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &New{} + controller.DisableTracking() // Display form request, _ := http.NewRequest("GET", "/new", strings.NewReader("")) diff --git a/internal/app/controller/web/random_test.go b/internal/app/controller/web/random_test.go index 5431524..15ca517 100644 --- a/internal/app/controller/web/random_test.go +++ b/internal/app/controller/web/random_test.go @@ -15,6 +15,7 @@ func TestRandom_Run(t *testing.T) { db := &database.MockSqlite{} container := &app.Container{Db: db} random := &Random{} + random.DisableTracking() // Test with a journal found db.Rows = &database.MockJournal_SingleRow{} diff --git a/internal/app/controller/web/sitemap_test.go b/internal/app/controller/web/sitemap_test.go index 5c73f0b..efbff12 100644 --- a/internal/app/controller/web/sitemap_test.go +++ b/internal/app/controller/web/sitemap_test.go @@ -28,6 +28,7 @@ func TestSitemap_Run(t *testing.T) { container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &Sitemap{} + controller.DisableTracking() // Test showing all Journals in sitemap db.Rows = &database.MockJournal_MultipleRows{} diff --git a/internal/app/controller/web/stats_test.go b/internal/app/controller/web/stats_test.go index 86c56d5..f437b8d 100644 --- a/internal/app/controller/web/stats_test.go +++ b/internal/app/controller/web/stats_test.go @@ -18,6 +18,7 @@ func TestStats_Run(t *testing.T) { container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &Stats{} + controller.DisableTracking() // Test with journals db.Rows = &database.MockJournal_MultipleRows{} diff --git a/internal/app/controller/web/view_test.go b/internal/app/controller/web/view_test.go index e5e3bdb..299dc4a 100644 --- a/internal/app/controller/web/view_test.go +++ b/internal/app/controller/web/view_test.go @@ -28,6 +28,7 @@ func TestView_Run(t *testing.T) { container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &View{} + controller.DisableTracking() // Test not found/error with GET/POST db.Rows = &database.MockRowsEmpty{} diff --git a/internal/app/model/visit.go b/internal/app/model/visit.go index 36c6223..787445a 100644 --- a/internal/app/model/visit.go +++ b/internal/app/model/visit.go @@ -1,6 +1,9 @@ package model import ( + "strconv" + "time" + "github.com/jamiefdhurst/journal/internal/app" ) @@ -30,3 +33,35 @@ func (vs *Visits) CreateTable() error { return err } + +// FindByDateAndURL finds a visit record for a specific date and URL +func (vs *Visits) FindByDateAndURL(date, url string) Visit { + visit := Visit{} + rows, err := vs.Container.Db.Query("SELECT * FROM `"+visitTable+"` WHERE `date` = ? AND `url` = ? LIMIT 1", date, url) + if err != nil { + return visit + } + defer rows.Close() + + if rows.Next() { + rows.Scan(&visit.ID, &visit.Date, &visit.URL, &visit.Hits) + return visit + } + + return Visit{} +} + +// RecordVisit records or updates a visit for the given URL and current date +func (vs *Visits) RecordVisit(url string) error { + today := time.Now().Format("2006-01-02") + + existingVisit := vs.FindByDateAndURL(today, url) + var err error + if existingVisit.ID > 0 { + _, err = vs.Container.Db.Exec("UPDATE `"+visitTable+"` SET `hits` = `hits` + 1 WHERE `id` = ?", strconv.Itoa(existingVisit.ID)) + } else { + _, err = vs.Container.Db.Exec("INSERT INTO `"+visitTable+"` (`date`, `url`, `hits`) VALUES (?, ?, 1)", today, url) + } + + return err +} diff --git a/internal/app/model/visit_test.go b/internal/app/model/visit_test.go index da06f00..7545e6d 100644 --- a/internal/app/model/visit_test.go +++ b/internal/app/model/visit_test.go @@ -11,8 +11,61 @@ func TestVisits_CreateTable(t *testing.T) { db := &database.MockSqlite{} container := &app.Container{Db: db} visits := Visits{Container: container} - visits.CreateTable() - if db.Queries != 1 { - t.Error("Expected 1 query to have been run") + + err := visits.CreateTable() + + if err != nil { + t.Errorf("Expected no error creating table, got: %s", err) + } +} + +func TestVisits_FindByDateAndURL(t *testing.T) { + db := &database.MockSqlite{} + container := &app.Container{Db: db} + visits := Visits{Container: container} + + db.Rows = &database.MockVisit_SingleRow{} + visit := visits.FindByDateAndURL("2023-01-01", "/test") + + if visit.ID != 1 { + t.Errorf("Expected visit ID to be 1, got %d", visit.ID) + } + if visit.URL != "/test" { + t.Errorf("Expected visit URL to be /test, got %s", visit.URL) + } + if visit.Hits != 5 { + t.Errorf("Expected visit hits to be 5, got %d", visit.Hits) + } + + // Test with no visit found + db.Rows = &database.MockRowsEmpty{} + emptyVisit := visits.FindByDateAndURL("2023-01-01", "/nonexistent") + + if emptyVisit.ID != 0 { + t.Errorf("Expected empty visit ID to be 0, got %d", emptyVisit.ID) + } +} + +func TestVisits_RecordVisit(t *testing.T) { + db := &database.MockSqlite{} + container := &app.Container{Db: db} + visits := Visits{Container: container} + + db.Rows = &database.MockRowsEmpty{} // No existing visit + db.Result = &database.MockResult{} + + err := visits.RecordVisit("/new-page") + + if err != nil { + t.Errorf("Expected no error recording new visit, got: %s", err) + } + + db.Rows = &database.MockVisit_SingleRow{} // Existing visit + db.Result = &database.MockResult{} + + err = visits.RecordVisit("/test") + + if err != nil { + t.Errorf("Expected no error updating existing visit, got: %s", err) } } diff --git a/journal_test.go b/journal_test.go index 6b202c8..175ca20 100644 --- a/journal_test.go +++ b/journal_test.go @@ -39,8 +39,11 @@ func fixtures(t *testing.T) { container.Db = db js := model.Journals{Container: container} + vs := model.Visits{Container: container} db.Exec("DROP TABLE journal") + db.Exec("DROP TABLE visit") js.CreateTable() + vs.CreateTable() // Set up data db.Exec("INSERT INTO journal (slug, title, content, date) VALUES (?, ?, ?, ?)", "test", "Test", "

Test!

", "2018-01-01") @@ -393,3 +396,58 @@ func TestWebStats(t *testing.T) { t.Error("Expected post count to be displayed") } } + +func TestVisitTracking(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("GET", server.URL+"/", nil) + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + + res.Body.Close() + + rows, err := container.Db.Query("SELECT COUNT(*) FROM visit WHERE url = '/'") + if err != nil { + t.Errorf("Failed to query visits table: %s", err) + return + } + defer rows.Close() + + var visitCount int + if rows.Next() { + rows.Scan(&visitCount) + } + + if visitCount == 0 { + t.Log("Visit tracking is disabled during test environment - this is expected behaviour") + } else { + t.Logf("Visit tracking is active - found %d visit(s)", visitCount) + + visitRows, err := container.Db.Query("SELECT url, hits FROM visit WHERE url = '/' LIMIT 1") + if err != nil { + t.Errorf("Failed to query visit details: %s", err) + return + } + defer visitRows.Close() + + if visitRows.Next() { + var url string + var hits int + visitRows.Scan(&url, &hits) + + if url != "/" { + t.Errorf("Expected visit URL to be '/', got '%s'", url) + } + if hits != 1 { + t.Errorf("Expected visit hits to be 1, got %d", hits) + } + } + } +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 3e17e59..c7882a6 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -3,6 +3,8 @@ package controller import ( "net/http" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" "github.com/jamiefdhurst/journal/pkg/session" ) @@ -20,11 +22,12 @@ type Controller interface { // Super Super-struct for all controllers. type Super struct { Controller - container interface{} - host string - params []string - session *session.Session - sessionStore session.Store + container interface{} + disableTracking bool + host string + params []string + session *session.Session + sessionStore session.Store } // Init Initialise the controller @@ -34,12 +37,18 @@ func (c *Super) Init(app interface{}, params []string, request *http.Request) { c.params = params c.sessionStore = session.NewDefaultStore("defaultdefaultdefaultdefault1234") c.session, _ = c.sessionStore.Get(request) + + c.trackVisit(request) } func (c *Super) Container() interface{} { return c.container } +func (c *Super) DisableTracking() { + c.disableTracking = true +} + func (c *Super) Host() string { return c.host } @@ -57,3 +66,21 @@ func (c *Super) SaveSession(w http.ResponseWriter) { func (c *Super) Session() *session.Session { return c.session } + +func (c *Super) trackVisit(request *http.Request) { + if c.disableTracking { + return + } + + if c.container == nil || request == nil || request.URL == nil { + return + } + + appContainer, ok := c.container.(*app.Container) + if !ok || appContainer.Db == nil { + return + } + + visits := model.Visits{Container: appContainer} + visits.RecordVisit(request.URL.Path) +} diff --git a/test/mocks/database/database.go b/test/mocks/database/database.go index 9199bed..cfb54ef 100644 --- a/test/mocks/database/database.go +++ b/test/mocks/database/database.go @@ -230,3 +230,29 @@ func (m *MockSqlite) popResult() rows.Rows { return result } + +// MockVisit_SingleRow Mock single row returned for a Visit +type MockVisit_SingleRow struct { + MockRowsEmpty + RowNumber int +} + +// Next Mock 1 row +func (m *MockVisit_SingleRow) Next() bool { + m.RowNumber++ + if m.RowNumber < 2 { + return true + } + return false +} + +// Scan Return the visit data +func (m *MockVisit_SingleRow) Scan(dest ...interface{}) error { + if m.RowNumber == 1 { + *dest[0].(*int) = 1 + *dest[1].(*string) = "2023-01-01" + *dest[2].(*string) = "/test" + *dest[3].(*int) = 5 + } + return nil +} From f46c71c4a7228fdd333483ad6cec232087dc566c Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Mon, 26 May 2025 10:15:25 +0100 Subject: [PATCH 26/52] Add visits into stats page and API --- api/README.md | 21 +++++- internal/app/controller/apiv1/stats.go | 10 +++ internal/app/controller/web/stats.go | 6 ++ internal/app/model/visit.go | 99 ++++++++++++++++++++++++++ internal/app/model/visit_test.go | 82 +++++++++++++++++++++ journal_test.go | 8 ++- test/mocks/database/database.go | 62 ++++++++++++++++ web/static/openapi.yml | 44 ++++++++++++ web/templates/stats.html.tmpl | 54 ++++++++++++++ web/themes/default/style.css | 45 ++++++++++++ 10 files changed, 429 insertions(+), 2 deletions(-) diff --git a/api/README.md b/api/README.md index cabbb9d..0a5ebb8 100644 --- a/api/README.md +++ b/api/README.md @@ -202,7 +202,8 @@ title and content must be provided. **Successful Response:** `200` -Retrieve statistics and configuration information on the current installation. +Retrieve statistics, configuration information and visit summaries for the +current installation. ```json { @@ -218,6 +219,24 @@ Retrieve statistics and configuration information on the current installation. "google_analytics": false, "create_enabled": true, "edit_enabled": true + }, + "visits": { + "daily": [ + { + "date": "2025-01-01", + "api_hits": 20, + "web_hits": 30, + "total": 50 + } + ], + "monthly": [ + { + "month": "2025-01", + "api_hits": 200, + "web_hits": 300, + "total": 500 + } + ] } } ``` diff --git a/internal/app/controller/apiv1/stats.go b/internal/app/controller/apiv1/stats.go index 0bfbd54..c473ef6 100644 --- a/internal/app/controller/apiv1/stats.go +++ b/internal/app/controller/apiv1/stats.go @@ -17,6 +17,12 @@ type Stats struct { type statsJSON struct { Posts statsPostsJSON `json:"posts"` Configuration statsConfigJSON `json:"configuration"` + Visits statsVisitsJSON `json:"visits"` +} + +type statsVisitsJSON struct { + Daily []model.DailyVisit `json:"daily"` + Monthly []model.MonthlyVisit `json:"monthly"` } type statsPostsJSON struct { @@ -57,6 +63,10 @@ func (c *Stats) Run(response http.ResponseWriter, request *http.Request) { stats.Configuration.CreateEnabled = container.Configuration.EnableCreate stats.Configuration.EditEnabled = container.Configuration.EnableEdit + vs := model.Visits{Container: container} + stats.Visits.Daily = vs.GetDailyStats(14) + stats.Visits.Monthly = vs.GetMonthlyStats() + // Send JSON response response.Header().Add("Content-Type", "application/json") encoder := json.NewEncoder(response) diff --git a/internal/app/controller/web/stats.go b/internal/app/controller/web/stats.go index 5156206..62b356f 100644 --- a/internal/app/controller/web/stats.go +++ b/internal/app/controller/web/stats.go @@ -25,6 +25,8 @@ type statsTemplateData struct { GACodeSet bool CreateEnabled bool EditEnabled bool + DailyVisits []model.DailyVisit + MonthlyVisits []model.MonthlyVisit } // Run Stats action @@ -55,6 +57,10 @@ func (c *Stats) Run(response http.ResponseWriter, request *http.Request) { data.CreateEnabled = container.Configuration.EnableCreate data.EditEnabled = container.Configuration.EnableEdit + vs := model.Visits{Container: container} + data.DailyVisits = vs.GetDailyStats(14) + data.MonthlyVisits = vs.GetMonthlyStats() + template, _ := template.ParseFiles( "./web/templates/_layout/default.html.tmpl", "./web/templates/stats.html.tmpl") diff --git a/internal/app/model/visit.go b/internal/app/model/visit.go index 787445a..e9921cd 100644 --- a/internal/app/model/visit.go +++ b/internal/app/model/visit.go @@ -1,6 +1,7 @@ package model import ( + "regexp" "strconv" "time" @@ -65,3 +66,101 @@ func (vs *Visits) RecordVisit(url string) error { return err } + +// DailyVisit represents daily visit statistics +type DailyVisit struct { + Date string `json:"date"` + APIHits int `json:"api_hits"` + WebHits int `json:"web_hits"` + Total int `json:"total"` +} + +// GetFriendlyDate returns a human-readable date format +func (d DailyVisit) GetFriendlyDate() string { + re := regexp.MustCompile(`\d{4}\-\d{2}\-\d{2}`) + date := re.FindString(d.Date) + timeObj, err := time.Parse("2006-01-02", date) + if err != nil { + return d.Date + } + return timeObj.Format("Monday January 2, 2006") +} + +// MonthlyVisit represents monthly visit statistics +type MonthlyVisit struct { + Month string `json:"month"` + APIHits int `json:"api_hits"` + WebHits int `json:"web_hits"` + Total int `json:"total"` +} + +// GetFriendlyMonth returns a human-readable month format +func (m MonthlyVisit) GetFriendlyMonth() string { + timeObj, err := time.Parse("2006-01", m.Month) + if err != nil { + return m.Month + } + return timeObj.Format("January 2006") +} + +// GetDailyStats returns visit statistics for the last N days +func (vs *Visits) GetDailyStats(days int) []DailyVisit { + // Calculate the date N days ago + startDate := time.Now().AddDate(0, 0, -days+1).Format("2006-01-02") + + query := ` + SELECT + date, + COALESCE(SUM(CASE WHEN url LIKE '/api/%' THEN hits ELSE 0 END), 0) as api_hits, + COALESCE(SUM(CASE WHEN url NOT LIKE '/api/%' THEN hits ELSE 0 END), 0) as web_hits, + COALESCE(SUM(hits), 0) as total + FROM ` + visitTable + ` + WHERE date >= ? + GROUP BY date + ORDER BY date DESC + ` + + rows, err := vs.Container.Db.Query(query, startDate) + if err != nil { + return []DailyVisit{} + } + defer rows.Close() + + var dailyStats []DailyVisit + for rows.Next() { + var stat DailyVisit + rows.Scan(&stat.Date, &stat.APIHits, &stat.WebHits, &stat.Total) + dailyStats = append(dailyStats, stat) + } + + return dailyStats +} + +// GetMonthlyStats returns visit statistics aggregated by month +func (vs *Visits) GetMonthlyStats() []MonthlyVisit { + query := ` + SELECT + strftime('%Y-%m', date) as month, + COALESCE(SUM(CASE WHEN url LIKE '/api/%' THEN hits ELSE 0 END), 0) as api_hits, + COALESCE(SUM(CASE WHEN url NOT LIKE '/api/%' THEN hits ELSE 0 END), 0) as web_hits, + COALESCE(SUM(hits), 0) as total + FROM ` + visitTable + ` + GROUP BY strftime('%Y-%m', date) + ORDER BY month DESC + ` + + rows, err := vs.Container.Db.Query(query) + if err != nil { + return []MonthlyVisit{} + } + defer rows.Close() + + var monthlyStats []MonthlyVisit + for rows.Next() { + var stat MonthlyVisit + rows.Scan(&stat.Month, &stat.APIHits, &stat.WebHits, &stat.Total) + monthlyStats = append(monthlyStats, stat) + } + + return monthlyStats +} diff --git a/internal/app/model/visit_test.go b/internal/app/model/visit_test.go index 7545e6d..b553830 100644 --- a/internal/app/model/visit_test.go +++ b/internal/app/model/visit_test.go @@ -69,3 +69,85 @@ func TestVisits_RecordVisit(t *testing.T) { t.Errorf("Expected no error updating existing visit, got: %s", err) } } + +func TestVisits_GetDailyStats(t *testing.T) { + db := &database.MockSqlite{} + container := &app.Container{Db: db} + visits := Visits{Container: container} + + // Test with mock data + db.Rows = &database.MockVisitStats_DailyRows{} + + dailyStats := visits.GetDailyStats(14) + + if len(dailyStats) != 2 { + t.Errorf("Expected 2 daily stats, got %d", len(dailyStats)) + } + + if len(dailyStats) > 0 { + if dailyStats[0].Date != "2023-12-25" { + t.Errorf("Expected first date to be 2023-12-25, got %s", dailyStats[0].Date) + } + if dailyStats[0].Total != 57 { + t.Errorf("Expected first total to be 57, got %d", dailyStats[0].Total) + } + } +} + +func TestVisits_GetMonthlyStats(t *testing.T) { + db := &database.MockSqlite{} + container := &app.Container{Db: db} + visits := Visits{Container: container} + + // Test with mock data + db.Rows = &database.MockVisitStats_MonthlyRows{} + + monthlyStats := visits.GetMonthlyStats() + + if len(monthlyStats) != 2 { + t.Errorf("Expected 2 monthly stats, got %d", len(monthlyStats)) + } + + if len(monthlyStats) > 0 { + if monthlyStats[0].Month != "2023-12" { + t.Errorf("Expected first month to be 2023-12, got %s", monthlyStats[0].Month) + } + if monthlyStats[0].Total != 1700 { + t.Errorf("Expected first total to be 1700, got %d", monthlyStats[0].Total) + } + } +} + +func TestDailyVisit_GetFriendlyDate(t *testing.T) { + visit := DailyVisit{Date: "2023-12-25"} + + friendly := visit.GetFriendlyDate() + expected := "Monday December 25, 2023" + + if friendly != expected { + t.Errorf("Expected friendly date to be %s, got %s", expected, friendly) + } + + // Test with invalid date + invalidVisit := DailyVisit{Date: "invalid-date"} + if invalidVisit.GetFriendlyDate() != "invalid-date" { + t.Error("Expected invalid date to return original string") + } +} + +func TestMonthlyVisit_GetFriendlyMonth(t *testing.T) { + visit := MonthlyVisit{Month: "2023-12"} + + friendly := visit.GetFriendlyMonth() + expected := "December 2023" + + if friendly != expected { + t.Errorf("Expected friendly month to be %s, got %s", expected, friendly) + } + + // Test with invalid month + invalidVisit := MonthlyVisit{Month: "invalid-month"} + if invalidVisit.GetFriendlyMonth() != "invalid-month" { + t.Error("Expected invalid month to return original string") + } +} diff --git a/journal_test.go b/journal_test.go index 175ca20..62645ac 100644 --- a/journal_test.go +++ b/journal_test.go @@ -335,7 +335,13 @@ func TestApiV1Stats(t *testing.T) { defer res.Body.Close() body, _ := io.ReadAll(res.Body) - expected := `{"posts":{"count":3,"first_post_date":"Monday January 1, 2018"},"configuration":{"title":"Jamie's Journal","description":"A private journal containing Jamie's innermost thoughts","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true}}` + + // Check that JSON is returned + if res.Header.Get("Content-Type") != "application/json" { + t.Error("Expected JSON content type") + } + + expected := `{"posts":{"count":3,"first_post_date":"Monday January 1, 2018"},"configuration":{"title":"Jamie's Journal","description":"A private journal containing Jamie's innermost thoughts","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true},"visits":{"daily":[{"date":"2025-05-26T00:00:00Z","api_hits":1,"web_hits":0,"total":1}],"monthly":[{"month":"2025-05","api_hits":1,"web_hits":0,"total":1}]}}` // Use contains to get rid of any extra whitespace that we can discount if !strings.Contains(string(body[:]), expected) { diff --git a/test/mocks/database/database.go b/test/mocks/database/database.go index cfb54ef..9e0667e 100644 --- a/test/mocks/database/database.go +++ b/test/mocks/database/database.go @@ -256,3 +256,65 @@ func (m *MockVisit_SingleRow) Scan(dest ...interface{}) error { } return nil } + +// MockVisitStats_DailyRows Mock daily visit statistics rows +type MockVisitStats_DailyRows struct { + MockRowsEmpty + RowNumber int +} + +// Next Mock 2 rows +func (m *MockVisitStats_DailyRows) Next() bool { + m.RowNumber++ + if m.RowNumber < 3 { + return true + } + return false +} + +// Scan Return the daily stats data +func (m *MockVisitStats_DailyRows) Scan(dest ...interface{}) error { + if m.RowNumber == 1 { + *dest[0].(*string) = "2023-12-25" + *dest[1].(*int) = 15 + *dest[2].(*int) = 42 + *dest[3].(*int) = 57 + } else if m.RowNumber == 2 { + *dest[0].(*string) = "2023-12-24" + *dest[1].(*int) = 8 + *dest[2].(*int) = 25 + *dest[3].(*int) = 33 + } + return nil +} + +// MockVisitStats_MonthlyRows Mock monthly visit statistics rows +type MockVisitStats_MonthlyRows struct { + MockRowsEmpty + RowNumber int +} + +// Next Mock 2 rows +func (m *MockVisitStats_MonthlyRows) Next() bool { + m.RowNumber++ + if m.RowNumber < 3 { + return true + } + return false +} + +// Scan Return the monthly stats data +func (m *MockVisitStats_MonthlyRows) Scan(dest ...interface{}) error { + if m.RowNumber == 1 { + *dest[0].(*string) = "2023-12" + *dest[1].(*int) = 450 + *dest[2].(*int) = 1250 + *dest[3].(*int) = 1700 + } else if m.RowNumber == 2 { + *dest[0].(*string) = "2023-11" + *dest[1].(*int) = 320 + *dest[2].(*int) = 980 + *dest[3].(*int) = 1300 + } + return nil +} diff --git a/web/static/openapi.yml b/web/static/openapi.yml index c79d176..aa8cb12 100644 --- a/web/static/openapi.yml +++ b/web/static/openapi.yml @@ -185,6 +185,7 @@ components: required: - posts - configuration + - visits type: object properties: posts: @@ -227,3 +228,46 @@ components: type: boolean edit_enabled: type: boolean + visits: + type: object + required: + - daily + - monthly + properties: + daily: + type: array + description: Daily visit statistics for the last 14 days + items: + type: object + properties: + date: + type: string + format: date + example: "2023-12-25" + api_hits: + type: integer + example: 15 + web_hits: + type: integer + example: 42 + total: + type: integer + example: 57 + monthly: + type: array + description: Monthly visit statistics for all available months + items: + type: object + properties: + month: + type: string + example: "2023-12" + api_hits: + type: integer + example: 450 + web_hits: + type: integer + example: 1250 + total: + type: integer + example: 1700 diff --git a/web/templates/stats.html.tmpl b/web/templates/stats.html.tmpl index d97a24a..679df08 100644 --- a/web/templates/stats.html.tmpl +++ b/web/templates/stats.html.tmpl @@ -36,6 +36,60 @@
Edit Posts
{{if .EditEnabled}}Enabled{{else}}Disabled{{end}}
+ +

Visits

+ +

Daily Visits (Last 14 Days)

+ {{if .DailyVisits}} + + + + + + + + + + + {{range .DailyVisits}} + + + + + + + {{end}} + +
DateWeb HitsAPI HitsTotal
{{.GetFriendlyDate}}{{.WebHits}}{{.APIHits}}{{.Total}}
+ {{else}} +

No visit data available for the last 14 days.

+ {{end}} + +

Monthly Visits

+ {{if .MonthlyVisits}} + + + + + + + + + + + {{range .MonthlyVisits}} + + + + + + + {{end}} + +
MonthWeb HitsAPI HitsTotal
{{.GetFriendlyMonth}}{{.WebHits}}{{.APIHits}}{{.Total}}
+ {{else}} +

No monthly visit data available.

+ {{end}} {{end}} \ No newline at end of file diff --git a/web/themes/default/style.css b/web/themes/default/style.css index 4feafbd..dc87106 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -497,3 +497,48 @@ section.stats dt:nth-of-type(odd), section.stats dd:nth-of-type(odd) { background-color: #f7f7f7; } + +/* Stats section adjustments for full width tables */ +section.stats { + max-width: none; + width: 95%; + margin: 0 auto; +} + +/* Visits table styling */ +.visits { + border-collapse: collapse; + font-size: 0.9rem; + margin: 1rem 0; + width: 100%; +} + +.visits th, +.visits td { + padding: 0.5rem; + text-align: left; + border-bottom: 1px solid #ddd; +} + +.visits th { + background-color: #f5f5f5; + font-weight: bold; + border-bottom: 2px solid #222; +} + +.visits tr:nth-child(even) { + background-color: #f9f9f9; +} + +.visits tr:hover { + background-color: #f0f0f0; +} + +.visits td:nth-child(2), +.visits td:nth-child(3), +.visits td:nth-child(4), +.visits th:nth-child(2), +.visits th:nth-child(3), +.visits th:nth-child(4) { + text-align: right; +} From d80c1b07ad7cbf6567b756bc5e667c718a9d225e Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Mon, 26 May 2025 10:39:02 +0100 Subject: [PATCH 27/52] Update SQLite driver to remove CGO and update to go 1.23 --- .github/workflows/build.yml | 4 ++-- .github/workflows/test.yml | 2 +- README.md | 8 +++++--- go.mod | 14 +++++++++++--- go.sum | 16 ++++++++++++---- mise.toml | 2 +- pkg/database/database.go | 3 ++- 7 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6de899..37cd026 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,13 +88,13 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: '1.22' + go-version: '1.23' cache-dependency-path: go.sum - name: Build Binary run: | sudo apt-get install -y build-essential libsqlite3-dev go mod download - CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal-bin_linux_x64-v${{ steps.version.outputs.value }} . + GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal-bin_linux_x64-v${{ steps.version.outputs.value }} . cp journal-bin_linux_x64-v${{ steps.version.outputs.value }} bootstrap zip -r journal-lambda_al2023-v${{ steps.version.outputs.value }}.zip bootstrap web -x web/app/\* - name: Create Release diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c02bd3a..6773baa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: '1.22' + go-version: '1.23' cache-dependency-path: go/src/github.com/jamiefdhurst/journal/go.sum - name: Install Dependencies working-directory: go/src/github.com/jamiefdhurst/journal diff --git a/README.md b/README.md index a9dae6d..2b817fb 100644 --- a/README.md +++ b/README.md @@ -98,15 +98,17 @@ the binary itself. #### Dependencies -The application currently only has one dependency: +The application has the following dependencies (using go.mod and go.sum): -* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) +- [github.com/ncruces/go-sqlite3](https://github.com/ncruces/go-sqlite3) +- [github.com/akrylysov/algnhsa](https://github.com/akrylysov/algnhsa) +- [github.com/aws/aws-lambda-go](https://github.com/aws/aws-lambda-go) +- [github.com/gomarkdown/markdown](https://github.com/gomarkdown/markdown) This can be installed using the following commands from the journal folder: ```bash go get -v ./... -go install -v ./... ``` #### Templates diff --git a/go.mod b/go.mod index 8fe249f..fe58364 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,21 @@ module github.com/jamiefdhurst/journal -go 1.22 +go 1.23.0 + +toolchain go1.23.9 require ( github.com/akrylysov/algnhsa v1.1.0 - github.com/mattn/go-sqlite3 v1.14.6 + github.com/ncruces/go-sqlite3 v0.25.2 +) + +require ( + github.com/ncruces/julianday v1.0.0 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + golang.org/x/sys v0.33.0 // indirect ) require ( - github.com/aws/aws-lambda-go v1.47.0 // indirect + github.com/aws/aws-lambda-go v1.48.0 // indirect github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b ) diff --git a/go.sum b/go.sum index db69b79..f6bf996 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,24 @@ github.com/akrylysov/algnhsa v1.1.0 h1:G0SoP16tMRyiism7VNc3JFA0wq/cVgEkp/ExMVnc6PQ= github.com/akrylysov/algnhsa v1.1.0/go.mod h1:+bOweRs/WBu5awl+ifCoSYAuKVPAmoTk8XOMrZ1xwiw= -github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI= -github.com/aws/aws-lambda-go v1.47.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= +github.com/aws/aws-lambda-go v1.48.0 h1:1aZUYsrJu0yo5fC4z+Rba1KhNImXcJcvHu763BxoyIo= +github.com/aws/aws-lambda-go v1.48.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk= github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= -github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/ncruces/go-sqlite3 v0.25.2 h1:suu3C7y92hPqozqO8+w3K333Q1VhWyN6K3JJKXdtC2U= +github.com/ncruces/go-sqlite3 v0.25.2/go.mod h1:46HIzeCQQ+aNleAxCli+vpA2tfh7ttSnw24kQahBc1o= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mise.toml b/mise.toml index c914115..ed714fd 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,2 @@ [tools] -go = "1.22" +go = "1.23" diff --git a/pkg/database/database.go b/pkg/database/database.go index 8c251c7..fecd8e0 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -5,7 +5,8 @@ import ( "os" "github.com/jamiefdhurst/journal/pkg/database/rows" - _ "github.com/mattn/go-sqlite3" // SQLite 3 driver + _ "github.com/ncruces/go-sqlite3/driver" // SQLite 3 driver + _ "github.com/ncruces/go-sqlite3/embed" // SQLite 3 embeddings ) // Database Define a common interface for all database drivers From 31b35128eb78f66a770787e89344287f54436728 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Mon, 26 May 2025 10:42:55 +0100 Subject: [PATCH 28/52] Remove CGO from Makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 881859b..24bcc3e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build test build: - @CC=x86_64-unknown-linux-gnu-gcc CGO_ENABLED=1 GOARCH=amd64 GOOS=linux go build -v -o bootstrap . + @CC=x86_64-unknown-linux-gnu-gcc GOARCH=amd64 GOOS=linux go build -v -o bootstrap . @zip -r lambda.zip bootstrap web -x web/app/\* test: From 5122f61da741539911ffaf22dc26c32149285c09 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 8 Jun 2025 11:22:24 +0100 Subject: [PATCH 29/52] Add basic SSL support --- .gitignore | 1 + go.mod | 10 ++-------- go.sum | 12 ------------ internal/app/app.go | 12 +++++++++--- journal.go | 22 +++++++++++----------- pkg/router/router.go | 6 ++++++ 6 files changed, 29 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index d333f3c..d39fe26 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ test/data/test.db .history bootstrap *.zip +*.pem diff --git a/go.mod b/go.mod index fe58364..25cfc45 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,7 @@ go 1.23.0 toolchain go1.23.9 -require ( - github.com/akrylysov/algnhsa v1.1.0 - github.com/ncruces/go-sqlite3 v0.25.2 -) +require github.com/ncruces/go-sqlite3 v0.25.2 require ( github.com/ncruces/julianday v1.0.0 // indirect @@ -15,7 +12,4 @@ require ( golang.org/x/sys v0.33.0 // indirect ) -require ( - github.com/aws/aws-lambda-go v1.48.0 // indirect - github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b -) +require github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b diff --git a/go.sum b/go.sum index f6bf996..ce224d0 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,12 @@ -github.com/akrylysov/algnhsa v1.1.0 h1:G0SoP16tMRyiism7VNc3JFA0wq/cVgEkp/ExMVnc6PQ= -github.com/akrylysov/algnhsa v1.1.0/go.mod h1:+bOweRs/WBu5awl+ifCoSYAuKVPAmoTk8XOMrZ1xwiw= -github.com/aws/aws-lambda-go v1.48.0 h1:1aZUYsrJu0yo5fC4z+Rba1KhNImXcJcvHu763BxoyIo= -github.com/aws/aws-lambda-go v1.48.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk= github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/ncruces/go-sqlite3 v0.25.2 h1:suu3C7y92hPqozqO8+w3K333Q1VhWyN6K3JJKXdtC2U= github.com/ncruces/go-sqlite3 v0.25.2/go.mod h1:46HIzeCQQ+aNleAxCli+vpA2tfh7ttSnw24kQahBc1o= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/app.go b/internal/app/app.go index 38605ef..2e583bc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -24,9 +24,9 @@ type MarkdownProcessor interface { // Container Define the main container for the application type Container struct { - Configuration Configuration - Db Database - Version string + Configuration Configuration + Db Database + Version string MarkdownProcessor MarkdownProcessor } @@ -39,6 +39,8 @@ type Configuration struct { EnableEdit bool GoogleAnalyticsCode string Port string + SSLCertificate string + SSLKey string StaticPath string Theme string ThemePath string @@ -55,6 +57,8 @@ func DefaultConfiguration() Configuration { EnableEdit: true, GoogleAnalyticsCode: "", Port: "3000", + SSLCertificate: "", + SSLKey: "", StaticPath: "web/static", Theme: "default", ThemePath: "web/themes", @@ -89,6 +93,8 @@ func ApplyEnvConfiguration(config *Configuration) { if port != "" { config.Port = port } + config.SSLCertificate = os.Getenv("J_SSL_CERT") + config.SSLKey = os.Getenv("J_SSL_KEY") staticPath := os.Getenv("J_STATIC_PATH") if staticPath != "" { config.StaticPath = staticPath diff --git a/journal.go b/journal.go index e6fea0a..8af8db2 100644 --- a/journal.go +++ b/journal.go @@ -1,13 +1,12 @@ package main import ( + "crypto/tls" "fmt" "log" "net/http" "os" - "github.com/akrylysov/algnhsa" - "github.com/jamiefdhurst/journal/internal/app" "github.com/jamiefdhurst/journal/internal/app/model" "github.com/jamiefdhurst/journal/internal/app/router" @@ -78,9 +77,6 @@ func loadDatabase() func() { func main() { const version = "0.9.6" - - // Set CWD - os.Chdir(os.Getenv("GOPATH") + "/src/github.com/jamiefdhurst/journal") fmt.Printf("Journal v%s\n-------------------\n\n", version) configuration := config() @@ -95,13 +91,17 @@ func main() { router := router.NewRouter(container) var err error - if lambdaRuntimeApi, _ := os.LookupEnv("AWS_LAMBDA_RUNTIME_API"); lambdaRuntimeApi != "" { - log.Printf("Ready for Lambda payload...\n") - algnhsa.ListenAndServe(router, nil) - } else { - server := &http.Server{Addr: ":" + configuration.Port, Handler: router} - log.Printf("Ready and listening on port %s...\n", configuration.Port) + server := &http.Server{Addr: ":" + configuration.Port, Handler: router, TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + }} + log.Printf("Ready and listening on port %s...\n", configuration.Port) + if configuration.SSLCertificate == "" { err = router.StartAndServe(server) + } else { + log.Printf("Certificate: %s\n", configuration.SSLCertificate) + log.Printf("Certificate Key: %s\n", configuration.SSLKey) + log.Println("Serving with SSL enabled...") + err = router.StartAndServeTLS(server, configuration.SSLCertificate, configuration.SSLKey) } if err != nil { diff --git a/pkg/router/router.go b/pkg/router/router.go index dce4560..34b0b13 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -13,6 +13,7 @@ import ( // Server Common interface for HTTP type Server interface { ListenAndServe() error + ListenAndServeTLS(string, string) error } // Route A route contains a method (GET), URI, and a controller @@ -100,3 +101,8 @@ func (r *Router) ServeHTTP(response http.ResponseWriter, request *http.Request) func (r *Router) StartAndServe(server Server) error { return server.ListenAndServe() } + +// StartAndServeTls Start the HTTP server and listen for connections with Tls +func (r *Router) StartAndServeTLS(server Server, cert string, key string) error { + return server.ListenAndServeTLS(cert, key) +} From 8f8f249b97694433bca45f9d5dbf6d84d06ebb41 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 8 Jun 2025 11:29:34 +0100 Subject: [PATCH 30/52] Force HTTP/2 to be available --- .github/workflows/build.yml | 2 +- .github/workflows/test.yml | 2 +- go.mod | 4 ++-- journal.go | 15 ++++++++++++--- mise.toml | 2 +- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 37cd026..afd8bdf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,7 +88,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: '1.23' + go-version: '1.24' cache-dependency-path: go.sum - name: Build Binary run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6773baa..a2099f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: '1.23' + go-version: '1.24' cache-dependency-path: go/src/github.com/jamiefdhurst/journal/go.sum - name: Install Dependencies working-directory: go/src/github.com/jamiefdhurst/journal diff --git a/go.mod b/go.mod index 25cfc45..e37fdf2 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/jamiefdhurst/journal -go 1.23.0 +go 1.24.0 -toolchain go1.23.9 +toolchain go1.24.2 require github.com/ncruces/go-sqlite3 v0.25.2 diff --git a/journal.go b/journal.go index 8af8db2..7afac21 100644 --- a/journal.go +++ b/journal.go @@ -91,9 +91,18 @@ func main() { router := router.NewRouter(container) var err error - server := &http.Server{Addr: ":" + configuration.Port, Handler: router, TLSConfig: &tls.Config{ - MinVersion: tls.VersionTLS13, - }} + var protocols http.Protocols + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + protocols.SetUnencryptedHTTP2(true) + server := &http.Server{ + Addr: ":" + configuration.Port, + Handler: router, + Protocols: &protocols, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + }, + } log.Printf("Ready and listening on port %s...\n", configuration.Port) if configuration.SSLCertificate == "" { err = router.StartAndServe(server) diff --git a/mise.toml b/mise.toml index ed714fd..886e9fb 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,2 @@ [tools] -go = "1.23" +go = "1.24" From d829cee1bf072f931d16a8fd547c07cf005b1b0d Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 8 Jun 2025 11:33:52 +0100 Subject: [PATCH 31/52] Add basic HSTS support --- pkg/router/router.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/router/router.go b/pkg/router/router.go index 34b0b13..50671f4 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -25,6 +25,7 @@ type Route struct { // Router A router contains routes and links back to the application and implements the ServeHTTP interface type Router struct { + isHTTPS bool Container interface{} Routes []Route StaticPaths []string @@ -67,6 +68,11 @@ func (r *Router) ServeHTTP(response http.ResponseWriter, request *http.Request) // Debug output into the console log.Printf("%s: %s", request.Method, request.URL.Path) + // Security headers + if r.isHTTPS { + request.Header.Add("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") + } + // Attempt to serve a file first from available static paths for _, staticPath := range r.StaticPaths { if request.URL.Path != "/" { @@ -99,10 +105,12 @@ func (r *Router) ServeHTTP(response http.ResponseWriter, request *http.Request) // StartAndServe Start the HTTP server and listen for connections func (r *Router) StartAndServe(server Server) error { + r.isHTTPS = false return server.ListenAndServe() } // StartAndServeTls Start the HTTP server and listen for connections with Tls func (r *Router) StartAndServeTLS(server Server, cert string, key string) error { + r.isHTTPS = true return server.ListenAndServeTLS(cert, key) } From 151e7c7eb5c93552b86c0c2b8f3cdc4038be4725 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 15 Jun 2025 10:27:24 +0100 Subject: [PATCH 32/52] Add XSS and CSP headers --- pkg/router/router.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/router/router.go b/pkg/router/router.go index 50671f4..b20dec8 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -72,6 +72,8 @@ func (r *Router) ServeHTTP(response http.ResponseWriter, request *http.Request) if r.isHTTPS { request.Header.Add("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") } + request.Header.Add("Content-Security-Policy", "default-src: 'self'; font-src: 'fonts.googleapis.com'; frame-src: 'none'") + request.Header.Add("X-XSS-Protection", "mode=block") // Attempt to serve a file first from available static paths for _, staticPath := range r.StaticPaths { From ac1cfc39a133c16ac70d838641623022277432c8 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 15 Jun 2025 11:05:41 +0100 Subject: [PATCH 33/52] Add relevant tests for additional headers --- .gitignore | 2 + journal_test.go | 7 +++- pkg/router/router.go | 8 ++-- pkg/router/router_test.go | 75 +++++++++++++++++++++++++++++++------ test/cert.pem | 31 +++++++++++++++ test/key.pem | 52 +++++++++++++++++++++++++ test/mocks/router/router.go | 6 +++ 7 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 test/cert.pem create mode 100644 test/key.pem diff --git a/.gitignore b/.gitignore index d39fe26..b3ab3c6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ test/data/test.db bootstrap *.zip *.pem +!test/cert.pem +!test/key.pem diff --git a/journal_test.go b/journal_test.go index 62645ac..93c8039 100644 --- a/journal_test.go +++ b/journal_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "io" "log" "net/http" @@ -8,6 +9,7 @@ import ( "os" "strings" "testing" + "time" "github.com/jamiefdhurst/journal/internal/app" "github.com/jamiefdhurst/journal/internal/app/model" @@ -341,7 +343,10 @@ func TestApiV1Stats(t *testing.T) { t.Error("Expected JSON content type") } - expected := `{"posts":{"count":3,"first_post_date":"Monday January 1, 2018"},"configuration":{"title":"Jamie's Journal","description":"A private journal containing Jamie's innermost thoughts","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true},"visits":{"daily":[{"date":"2025-05-26T00:00:00Z","api_hits":1,"web_hits":0,"total":1}],"monthly":[{"month":"2025-05","api_hits":1,"web_hits":0,"total":1}]}}` + now := time.Now() + date := now.Format("2006-01-02") + month := now.Format("2006-01") + expected := fmt.Sprintf(`{"posts":{"count":3,"first_post_date":"Monday January 1, 2018"},"configuration":{"title":"Jamie's Journal","description":"A private journal containing Jamie's innermost thoughts","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true},"visits":{"daily":[{"date":"%sT00:00:00Z","api_hits":1,"web_hits":0,"total":1}],"monthly":[{"month":"%s","api_hits":1,"web_hits":0,"total":1}]}}`, date, month) // Use contains to get rid of any extra whitespace that we can discount if !strings.Contains(string(body[:]), expected) { diff --git a/pkg/router/router.go b/pkg/router/router.go index b20dec8..6b9f85b 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -25,7 +25,7 @@ type Route struct { // Router A router contains routes and links back to the application and implements the ServeHTTP interface type Router struct { - isHTTPS bool + isHTTPS bool `default:"false"` Container interface{} Routes []Route StaticPaths []string @@ -70,10 +70,10 @@ func (r *Router) ServeHTTP(response http.ResponseWriter, request *http.Request) // Security headers if r.isHTTPS { - request.Header.Add("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") + response.Header().Add("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") } - request.Header.Add("Content-Security-Policy", "default-src: 'self'; font-src: 'fonts.googleapis.com'; frame-src: 'none'") - request.Header.Add("X-XSS-Protection", "mode=block") + response.Header().Add("Content-Security-Policy", "default-src: 'self'; font-src: 'fonts.googleapis.com'; frame-src: 'none'") + response.Header().Add("X-XSS-Protection", "mode=block") // Attempt to serve a file first from available static paths for _, staticPath := range r.StaticPaths { diff --git a/pkg/router/router_test.go b/pkg/router/router_test.go index 1ed42c5..3fa6268 100644 --- a/pkg/router/router_test.go +++ b/pkg/router/router_test.go @@ -2,7 +2,6 @@ package router import ( "net/http" - "net/url" "os" "path" "runtime" @@ -86,8 +85,7 @@ func TestServeHTTP(t *testing.T) { router.Get("/", indexController) // Serve static file - staticURL := &url.URL{Path: "/style.css"} - staticRequest := &http.Request{URL: staticURL, Method: "GET"} + staticRequest, _ := http.NewRequest("GET", "/style.css", nil) router.ServeHTTP(response, staticRequest) if errorController.HasRun { t.Errorf("Expected static file to have been served but error controller was run") @@ -95,8 +93,7 @@ func TestServeHTTP(t *testing.T) { } // Index - indexURL := &url.URL{Path: "/"} - indexRequest := &http.Request{URL: indexURL, Method: "GET"} + indexRequest, _ := http.NewRequest("GET", "/", nil) router.ServeHTTP(response, indexRequest) if !indexController.HasRun || errorController.HasRun { t.Errorf("Expected index controller to have been served but error controller was run") @@ -104,8 +101,7 @@ func TestServeHTTP(t *testing.T) { } // Standard route - standardURL := &url.URL{Path: "/standard"} - standardRequest := &http.Request{URL: standardURL, Method: "GET"} + standardRequest, _ := http.NewRequest("GET", "/standard", nil) router.ServeHTTP(response, standardRequest) if !standardController.HasRun || errorController.HasRun { t.Errorf("Expected standard controller to have been served but error controller was run") @@ -113,8 +109,7 @@ func TestServeHTTP(t *testing.T) { } // Param route - paramURL := &url.URL{Path: "/param/test1"} - paramRequest := &http.Request{URL: paramURL, Method: "GET"} + paramRequest, _ := http.NewRequest("GET", "/param/test1", nil) router.ServeHTTP(response, paramRequest) if !paramController.HasRun || errorController.HasRun { t.Errorf("Expected param controller to have been served but error controller was run") @@ -122,14 +117,61 @@ func TestServeHTTP(t *testing.T) { } // Not found route - notFoundURL := &url.URL{Path: "/random"} - notFoundRequest := &http.Request{URL: notFoundURL, Method: "GET"} + notFoundRequest, _ := http.NewRequest("GET", "/random", nil) router.ServeHTTP(response, notFoundRequest) if !errorController.HasRun { t.Errorf("Expected error controller to have been served") } } +func TestServeHTTP_HTTPHeaders(t *testing.T) { + ctrl := &controller.MockController{} + router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} + server := &mockRouter.MockServer{} + router.StartAndServe(server) + + response := controller.NewMockResponse() + request, _ := http.NewRequest("GET", "/random", nil) + router.ServeHTTP(response, request) + + csp := response.Headers.Get("Content-Security-Policy") + xss := response.Headers.Get("X-XSS-Protection") + sts := response.Headers.Get("Strict-Transport-Security") + if csp == "" { + t.Error("Expected CSP header to be present") + } + if xss == "" { + t.Error("Expected XSS header to be present") + } + if sts != "" { + t.Error("Expected STS header to not be present") + } +} + +func TestServeHTTP_HTTPSHeaders(t *testing.T) { + ctrl := &controller.MockController{} + router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} + server := &mockRouter.MockServer{} + router.StartAndServeTLS(server, "test/cert.pem", "test/key.pem") + + response := controller.NewMockResponse() + request, _ := http.NewRequest("GET", "/random", nil) + router.ServeHTTP(response, request) + + csp := response.Headers.Get("Content-Security-Policy") + xss := response.Headers.Get("X-XSS-Protection") + sts := response.Headers.Get("Strict-Transport-Security") + if csp == "" { + t.Error("Expected CSP header to be present") + } + if xss == "" { + t.Error("Expected XSS header to be present") + } + if sts == "" { + t.Error("Expected STS header to be present") + } +} + func TestStartAndServe(t *testing.T) { ctrl := &controller.MockController{} router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} @@ -140,3 +182,14 @@ func TestStartAndServe(t *testing.T) { t.Errorf("Expected some routes to have been defined but none were found") } } + +func TestStartAndServeTLS(t *testing.T) { + ctrl := &controller.MockController{} + router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} + server := &mockRouter.MockServer{} + router.StartAndServeTLS(server, "test/cert.pem", "test/key.pem") + + if !server.Listening { + t.Errorf("Expected some routes to have been defined but none were found") + } +} diff --git a/test/cert.pem b/test/cert.pem new file mode 100644 index 0000000..3b1df17 --- /dev/null +++ b/test/cert.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFYDCCA0gCCQDbCFfrdhlrnDANBgkqhkiG9w0BAQsFADByMQswCQYDVQQGEwJV +SzESMBAGA1UEBwwJTmV3Y2FzdGxlMRQwEgYDVQQKDAtKYW1pZSBIdXJzdDESMBAG +A1UEAwwJbG9jYWxob3N0MSUwIwYJKoZIhvcNAQkBFhZqYW1pZUBqYW1pZWh1cnN0 +LmNvLnVrMB4XDTI1MDYwODEwMTU1MloXDTM1MDYwNjEwMTU1MlowcjELMAkGA1UE +BhMCVUsxEjAQBgNVBAcMCU5ld2Nhc3RsZTEUMBIGA1UECgwLSmFtaWUgSHVyc3Qx +EjAQBgNVBAMMCWxvY2FsaG9zdDElMCMGCSqGSIb3DQEJARYWamFtaWVAamFtaWVo +dXJzdC5jby51azCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALgAYu6D +rmngbGlbSacwmnC+HgCIeFb0K4PaYK5NtiZHe85B0ceQupPbPUiaK8U+tmVRRD9K +QL2wcgoVcTrSBMS7YsAFxBEHkozRpIfE0Tgfl+cL3eSNVDABK2m5L/Ypq7/IAK2r +A5HAwljcca0iLmEyT2CkoXycI0pu1AOieQoa6rGAcfIe1heeRG8L256B+vXWqV48 +4nJDFVU6jPuzNwdSWETcGjxFMobZf6NJ07bqJOZRAUrNChdgYGn2y1nqtPEPL2i0 +C0SNOKL8WGdGB0WpmqCaHYeMHhA9i/2Xuq5Fa/QaCobPcB3MWHGVEOQkrgouaYAm +XhEp0vqZZhQqpItJ+5MkDD0l4J2xkVObjByxm2vgx29C30e/EB/G8vF+wIfsbgKt +klSUo5RnxB2X1k1+1OiRQSvYGT0PnHEVFjOK6KAS7BmUVk/LSfa/qJGPt+l3m7eA +0kKqoH2ONya9P4uq0pwhbJEAyW3IRnSC/Ez4XXOVQeTiH9lLjjIWKZ2ObKfusIcm +ni5MfY6JfQsRgi/Y0TgbVXbL18IrJkKRxGQvbe6dTZbg44AjEM7f157yiu+aBQvl +m/o6y1+klQI+1DfcGReDpJsvY7otVYE0t3BaP7f23143YM/Wh005PerKGjKNR/Qh +b1by1ZR0nZAosiWvCj+vsFx40bUZRo6snkIDAgMBAAEwDQYJKoZIhvcNAQELBQAD +ggIBACTQavkswMH3zDly0+Imr9USpCuu8hdwBPz+zNRfaFzc/gkHPJk2pNH4pARn +ZcFfGgPd2Kvq6ENppyL7CuRy4Y/Mdw84aCXTi4koaoVVML3rdV+Gqm/Wbv7Wqh94 +WBRyrd8tzs1KQbp1xH0L0FfuLw8al9ryxSl/cLB3y3Us9boHC5jv/RLGJJnSKmU/ +/a0Q22HPTIhbjZDC+VUYF0g3E5s9Pb2yAxP4ECFvjyypKa8qvQaxZmnJQgmDrvgq +xhmbg2ylbqxQUdB003v2LzWccFGkeuFjU+9/ADZAewdMs0NnBI9P65jU5Pa39L/K +jm77vCShd6qGB/2eHGCXKFYRlMW5sHtlYUcCCGDi7SJ9beBqK1ifb0vOsGcj4DA5 +4/RFQYmc9nuF6+gr4sul2Q2H2cGuacJ68QjbCYrUSTcCGry+629HrTozI0KXaF2+ +cnSKF3Uv/LTpAN9xw4cI/bvHkNkc8ULN4ao/Q8xVBlSR7IufUErf02wlhm0U1WcB +DMnVTdG2/H8q8Jzy+6fTMIeaLj/kNFhAe/vp6/akLsARWvXxGFdorxCcXf41YlKA +tGAte/r6le5/lKSrJjXtBw7LFRuuWJGM8nh82sTLhBUqZnS0TiTxGyas/0nj2aqo +6iZ9cxcwuCS5RBIOHGiLobB1A4JP+Nd/mUMmreov75csOrpw +-----END CERTIFICATE----- diff --git a/test/key.pem b/test/key.pem new file mode 100644 index 0000000..c8c3124 --- /dev/null +++ b/test/key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJRAIBADANBgkqhkiG9w0BAQEFAASCCS4wggkqAgEAAoICAQC4AGLug65p4Gxp +W0mnMJpwvh4AiHhW9CuD2mCuTbYmR3vOQdHHkLqT2z1ImivFPrZlUUQ/SkC9sHIK +FXE60gTEu2LABcQRB5KM0aSHxNE4H5fnC93kjVQwAStpuS/2Kau/yACtqwORwMJY +3HGtIi5hMk9gpKF8nCNKbtQDonkKGuqxgHHyHtYXnkRvC9uegfr11qlePOJyQxVV +Ooz7szcHUlhE3Bo8RTKG2X+jSdO26iTmUQFKzQoXYGBp9stZ6rTxDy9otAtEjTii +/FhnRgdFqZqgmh2HjB4QPYv9l7quRWv0GgqGz3AdzFhxlRDkJK4KLmmAJl4RKdL6 +mWYUKqSLSfuTJAw9JeCdsZFTm4wcsZtr4MdvQt9HvxAfxvLxfsCH7G4CrZJUlKOU +Z8Qdl9ZNftTokUEr2Bk9D5xxFRYziuigEuwZlFZPy0n2v6iRj7fpd5u3gNJCqqB9 +jjcmvT+LqtKcIWyRAMltyEZ0gvxM+F1zlUHk4h/ZS44yFimdjmyn7rCHJp4uTH2O +iX0LEYIv2NE4G1V2y9fCKyZCkcRkL23unU2W4OOAIxDO39ee8orvmgUL5Zv6Ostf +pJUCPtQ33BkXg6SbL2O6LVWBNLdwWj+39t9eN2DP1odNOT3qyhoyjUf0IW9W8tWU +dJ2QKLIlrwo/r7BceNG1GUaOrJ5CAwIDAQABAoICAQCbTWAzNpu4q351cmJ5JfHE +pQLHqmf/5HjyAhjGJbtPFdiuXymDymlgMJTKOa4l/meOnof+71ozgMDQOAbpAaia +sBqKPpOdWAnep3e6TGnWd/wLPB3eMVdUaThONMsBd2yKI3JHIueRVuPygqXD3uzM +ht0ukeXnOhYjVeXG55RH7i4XAXWrSVGkf6X9IEIOyGCcrMEpVDRBAtP3qsKiE0Ko +AF2WSTwvkKwz21H67W4vnfLlHov7qZIR5vuZlH9QdmSgbhOyyPwVsSiTkG/BQv8S +UjO7yDiSVrZtOLV2pmEfhGK4ll46KM3VqMshmxK1rSvkVgYf7sJItEdp0p2w+ckE +hUxkuVLEx59MrDb3qktZEiQ3vAEhq2c9CIyesaORP2/zhQS8CWlot6uxqiaH/O8u +Vk0ZG1kcMLZhqOJL6zDfubH6QNS9KLRM5Iz/D7Kj8860dtGh+PuW/0j5Wp8YLu3e +aPkWHBzA9KkgdOHfIuh6tujeNF3n4QTg6gRIx5d+wZUBemfsEpqJtUeJDgbkKluz +Q3uYhsTtfO90dtqSFWC1VQCTGse49jRePKG+zHp2GQ7Jv8j729cHJIOoXvr43bLf ++pZ2a80cHUqH0818pN6oiDPwSGC4wWFAqcB1q5S5ZxKdgpThEY5XgWmDRMOfGUl3 +UWyVoHHzARN4XJQLcKllCQKCAQEA3V3p3Oa7gk98BJ69cqdrfhHzX8uxjeezOwrs +MzkcyMzX5PceCDyhXMtFURtKZ9ITejG1DkXKgql7qXPvPINkHBvAsblPUVsuXln6 +EsVTcpRoXWC0ZrIXS6oZ3ae5dDvS7fd4IDS6YHSW9cVw57NEiNNL+4YzbnXUGSNC +yAXMBDHOt6SQDs4eqB2eHKdRIQTR4XnqqRZz0UJVoqpEwol8VAxNXUne2s5cHEXb +O+4tRq0FeI1OTAqsK9MrBk3ToIQH3RwH7bkS8DVh+CkQMh3Bhd39oWMKPeTSc/3V +chN23101I3CpbbKMbH7353P9cAHcJf7etBc8NRSXgwMnhT3/pQKCAQEA1Mnu1rJe +m4S1YbdCobVPArSJAtLZZNxdgNCPn3h89o3E5aooXgTtIhBBeTn/JHy+gLVmwq+X +9GXV+o1In2ZOtLVMLl+RAGGhLum48gXPosUxX3ARpSdr8SJ0Y7gGY9C1tjLXc5F/ +QGo3LGNiophAurgvRKHc6gEVUk8BaSN9Aiqnmuwv1vBNnyOqqgIv3+TpdFX0qjcu +B409XzrWk+1n6CoY+J5aIpNtumJJX6k2o2hGfDhe2v/U8w3cVLH9G/J6/cTw9L8B +mMWzDMmyDjvbWO02+F6Wbsb/CFAS2ErZYg27i9iUfCOQ0aG+nfxfJ//mjlNTe5C+ +GWF26GxJdjAKhwKCAQEA3MkvWIDU4jqOsjj1MSakgqA6wf/yfltrGudhABHlkK0m +Y5rJXGPEeT3QS/3RL02K2aQ8NhkLy1hpG3CjWxKdRZ+0iE4QO0+bJsXNMu2WtkAo ++4FZTNgxfekRVU9VHAYS8f+R02VjwpJmgojDfIUDRQihzyNhprlkqxHNKJ0Hh+N5 +jxZWDD4uu3SW33NN6oXZI28qyiy3pS3pJY13eSQRWe7PNs1XtZp+qkBOUi7S/5vQ +ShV900ANysQaNHZpLb6h7Tlo+wRNTEGiDhY+rg2Zl//6WP3kGClicgfo3JdnR466 +Ujeq9NtRTWExtqqsSwu/3DGhQ7Os/DAmkagSwcU9dQKCAQEAkcpA76yKEXedZnPP +HUhB+BKFhP+9ntM05RsALDy7MZn0e35X5gLuDdahZVONMgyd4UVoQJ9aN0LGlsHS +LhREfJ9ysJsdl+tMKf5MjtXYayc8Kq14CXW3CSGYKPJevmiy90BiSXY4f4PGhY0a +eVhjkQq8qANWfqV7XEdxKf38mk1rREPqixNdu1kOhyi0cGxAX0q9NRpVWSs2D1ca +yYNxG6osLbsg+muUVI0exIIFQ3QgRt/Abb+2wUiP2x+P0WQTTGdwx99OUsOxZ2OR +sRrlsEnmzcjQvNluxt1F7BdsVTgfdTNQmLUtddOh7FCLSbaU2pLQsep7tJwIgjof +IvDLZQKCAQBMtbnRiu+fr4YQw4MHGrkJxQYtjxn51599Mbil7SPUFkQompqONdXI +bTbBQcU0rjwicP/Udn/cL+a3DQbny62Gqti2el/bka8ypgyzJ2ljRfRJ8B83FlK9 +lqQhLii4a5RTAYbnj5nttEHL3IlH2dXeeyu2acKJFBqL1L1i588U++TorAKSat7a +GMBUk4yqgtFG6jQSljCokgHCJgeFj8Kc5Yq9eCALqa0e3EScVclWUurzCF8VPr4U +j7cqOkmjQ5d1hXAmE1ZiWoN83fSfMLK54B/IFvhQJaKsDymCtHCJQ+64juWqI6rR +VRKm6hutR/CWeJ3HRw2qep2TzbKVapTN +-----END PRIVATE KEY----- diff --git a/test/mocks/router/router.go b/test/mocks/router/router.go index 75447fa..cbc8f74 100644 --- a/test/mocks/router/router.go +++ b/test/mocks/router/router.go @@ -10,3 +10,9 @@ func (m *MockServer) ListenAndServe() error { m.Listening = true return nil } + +// ListenAndServeTLS Dummy method +func (m *MockServer) ListenAndServeTLS(cert string, key string) error { + m.Listening = true + return nil +} From 8e8c9367df37a127cb7000233be6b9f1accae91c Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 21 Sep 2025 10:21:18 +0100 Subject: [PATCH 34/52] [WIP] Calendar --- internal/app/controller/web/calendar.go | 32 ++++++ internal/app/router/router.go | 16 ++- web/templates/_layout/default.html.tmpl | 5 +- web/templates/calendar.html.tmpl | 141 ++++++++++++++++++++++++ web/themes/default/style.css | 72 +++++++++++- 5 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 internal/app/controller/web/calendar.go create mode 100644 web/templates/calendar.html.tmpl diff --git a/internal/app/controller/web/calendar.go b/internal/app/controller/web/calendar.go new file mode 100644 index 0000000..7b2d6fc --- /dev/null +++ b/internal/app/controller/web/calendar.go @@ -0,0 +1,32 @@ +package web + +import ( + "net/http" + "text/template" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/pkg/controller" +) + +// Calendar Handle displaying a calendar with blog entries for given days +type Calendar struct { + controller.Super +} + +type calendarTemplateData struct { + Container interface{} +} + +// Run Calendar action +func (c *Calendar) Run(response http.ResponseWriter, request *http.Request) { + + data := calendarTemplateData{} + + container := c.Super.Container().(*app.Container) + data.Container = container + + template, _ := template.ParseFiles( + "./web/templates/_layout/default.html.tmpl", + "./web/templates/calendar.html.tmpl") + template.ExecuteTemplate(response, "layout", data) +} diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 170a6ac..8e33bc5 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -17,17 +17,23 @@ func NewRouter(app *app.Container) *pkgrouter.Router { app.Configuration.StaticPath, } - rtr.Get("/sitemap.xml", &web.Sitemap{}) - rtr.Get("/stats", &web.Stats{}) - rtr.Get("/new", &web.New{}) - rtr.Post("/new", &web.New{}) - rtr.Get("/random", &web.Random{}) + // API v1 rtr.Get("/api/v1/stats", &apiv1.Stats{}) rtr.Get("/api/v1/post", &apiv1.List{}) rtr.Put("/api/v1/post", &apiv1.Create{}) rtr.Get("/api/v1/post/random", &apiv1.Random{}) rtr.Get("/api/v1/post/[%s]", &apiv1.Single{}) rtr.Post("/api/v1/post/[%s]", &apiv1.Update{}) + + // Web + rtr.Get("/sitemap.xml", &web.Sitemap{}) + rtr.Get("/stats", &web.Stats{}) + rtr.Get("/new", &web.New{}) + rtr.Post("/new", &web.New{}) + rtr.Get("/random", &web.Random{}) + rtr.Get("/calendar/[%s]/[%s]", &web.Calendar{}) + rtr.Get("/calendar/[%s]", &web.Calendar{}) + rtr.Get("/calendar", &web.Calendar{}) rtr.Get("/[%s]/edit", &web.Edit{}) rtr.Post("/[%s]/edit", &web.Edit{}) rtr.Get("/[%s]", &web.View{}) diff --git a/web/templates/_layout/default.html.tmpl b/web/templates/_layout/default.html.tmpl index 97365fb..ad31246 100644 --- a/web/templates/_layout/default.html.tmpl +++ b/web/templates/_layout/default.html.tmpl @@ -16,9 +16,10 @@
{{.Container.Configuration.Title}}
diff --git a/web/templates/calendar.html.tmpl b/web/templates/calendar.html.tmpl new file mode 100644 index 0000000..350f8d2 --- /dev/null +++ b/web/templates/calendar.html.tmpl @@ -0,0 +1,141 @@ +{{define "title"}}Calendar - {{end}} +{{define "content"}} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SunMonTueWedThuFriSat
    +

1

+
+

2

+
+

3

+
+

4

+
+

5

+
+

6

+
+

7

+
+

8

+
+

9

+
+

10

+
+

11

+
+

12

+
+

13

+
+

14

+
+

15

+
+

16

+
+

17

+
+

18

+
+

19

+
+

20

+
+

21

+
+

22

+
+

23

+
+

24

+ Big Mouth +
+

25

+
+

26

+
+

27

+
+

28

+
+

29

+
+

30

+
+

31

+
 
+
+ + +{{end}} diff --git a/web/themes/default/style.css b/web/themes/default/style.css index dc87106..23d9cfe 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -85,7 +85,12 @@ header[role=banner] p { padding-top: .5rem; } -#menu .button { +#menu{ + padding-top: .375rem; +} + +#menu a { + font-size: 1rem; margin-left: 15px; } @@ -542,3 +547,68 @@ section.stats { .visits th:nth-child(4) { text-align: right; } + +.calendar-top { + clear: both; + position: relative; + text-align: center; +} + +.calendar-top h2 { + margin: 0 auto 2rem; +} + +.calendar-top a:nth-child(2) { + left: 0; + position: absolute; + text-align: left; + top: .5rem; +} + +.calendar-top a:nth-child(3) { + position: absolute; + right: 0; + top: .5rem; + text-align: right; +} + +.calendar { + border: 2px solid #111; + border-collapse: collapse; + margin-top: 4rem; + width: 100%; +} + +.calendar th, +.calendar td { + border: 1px solid #dedede; + padding: .25rem .5rem; + vertical-align: top; +} + +.calendar th { + background-color: #dedede; + font-weight: bold; + text-align: right; +} + +.calendar td { + height: 3rem; + padding-top: 2.25rem; + position: relative; +} + +.calendar td h3 { + font-size: 1.25rem; + margin: 0; + position: absolute; + right: .5rem; + text-align: right; + top: .5rem; +} + +.calendar td a { + font-size: 1rem; + font-style: italic; + line-height: 1.25rem; +} \ No newline at end of file From 21465518d6f1e01d78c57531a5aaa6733780ddd8 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 5 Oct 2025 20:04:26 +0100 Subject: [PATCH 35/52] [WIP] Calendar functionality --- go.mod | 5 +- internal/app/controller/web/calendar.go | 106 +++++++++++++++++- internal/app/model/journal.go | 10 ++ web/templates/calendar.html.tmpl | 143 +++++------------------- web/themes/default/style.css | 38 +++++-- 5 files changed, 178 insertions(+), 124 deletions(-) diff --git a/go.mod b/go.mod index e37fdf2..b99d9dd 100644 --- a/go.mod +++ b/go.mod @@ -12,4 +12,7 @@ require ( golang.org/x/sys v0.33.0 // indirect ) -require github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b +require ( + github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b + golang.org/x/text v0.25.0 +) diff --git a/internal/app/controller/web/calendar.go b/internal/app/controller/web/calendar.go index 7b2d6fc..db788f0 100644 --- a/internal/app/controller/web/calendar.go +++ b/internal/app/controller/web/calendar.go @@ -2,10 +2,16 @@ package web import ( "net/http" + "strconv" + "strings" "text/template" + "time" "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" "github.com/jamiefdhurst/journal/pkg/controller" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) // Calendar Handle displaying a calendar with blog entries for given days @@ -13,8 +19,24 @@ type Calendar struct { controller.Super } +type day struct { + Date time.Time + IsEmpty bool +} + type calendarTemplateData struct { - Container interface{} + Container interface{} + Days map[int][]model.Journal + Weeks [][]day + CurrentDate time.Time + PrevYear int + PrevYearUrl string + NextYear int + NextYearUrl string + PrevMonth string + PrevMonthUrl string + NextMonth string + NextMonthUrl string } // Run Calendar action @@ -24,6 +46,88 @@ func (c *Calendar) Run(response http.ResponseWriter, request *http.Request) { container := c.Super.Container().(*app.Container) data.Container = container + js := model.Journals{Container: container} + + // Load date from parameters if available (either 2006/jan or 2006) + date := time.Now() + var err error + if len(c.Params()) == 3 { + date, err = time.Parse("2006 Jan 02", c.Params()[1]+" "+cases.Title(language.English, cases.NoLower).String(c.Params()[2])+" 25") + } else if len(c.Params()) == 2 { + date, err = time.Parse("2006-01-02", c.Params()[1]+"-01-01") + } + if err != nil { + RunBadRequest(response, request, c.Super.Container) + return + } + + firstOfMonth := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location()) + startWeekday := int(firstOfMonth.Weekday()) + + // Find number of days in month + nextMonth := firstOfMonth.AddDate(0, 1, 0) + lastOfMonth := nextMonth.AddDate(0, 0, -1) + daysInMonth := lastOfMonth.Day() + + data.Days = map[int][]model.Journal{} + data.Weeks = [][]day{} + week := []day{} + + // Fill in blanks before first day + for range startWeekday { + week = append(week, day{IsEmpty: true}) + } + + // Fill in actual days + for d := 1; d <= daysInMonth; d++ { + thisDate := time.Date(date.Year(), date.Month(), d, 0, 0, 0, 0, date.Location()) + data.Days[d] = js.FetchByDate(thisDate.Format("2006-01-02")) + week = append(week, day{ + Date: thisDate, + IsEmpty: false, + }) + + // If Saturday, start a new week + if thisDate.Weekday() == time.Saturday { + data.Weeks = append(data.Weeks, week) + week = []day{} + } + } + + // Fill in blanks after last day + if len(week) > 0 { + for len(week) < 7 { + week = append(week, day{IsEmpty: true}) + } + data.Weeks = append(data.Weeks, week) + } + + // Load prev/next year and month + firstEntry := js.FindNext(0) + firstEntryDate, _ := time.Parse("2006-01-02", firstEntry.GetEditableDate()) + if date.Year() < time.Now().Year() { + data.NextYear = date.Year() + 1 + data.NextYearUrl = strconv.Itoa(data.NextYear) + "/" + strings.ToLower(date.Format("Jan")) + if date.AddDate(1, 0, 0).After(time.Now()) { + data.NextYearUrl = strconv.Itoa(data.NextYear) + "/" + strings.ToLower(time.Now().Format("Jan")) + } + } + if date.Year() > firstEntryDate.Year() { + data.PrevYear = date.Year() - 1 + data.PrevYearUrl = strconv.Itoa(data.PrevYear) + "/" + strings.ToLower(date.Format("Jan")) + if date.AddDate(-1, 0, 0).Before(firstEntryDate) { + data.PrevYearUrl = strconv.Itoa(data.PrevYear) + "/" + strings.ToLower(firstEntryDate.Format("Jan")) + } + } + if date.Year() < time.Now().Year() || date.Month() < time.Now().Month() { + data.NextMonth = date.AddDate(0, 0, 31).Format("January") + data.NextMonthUrl = strings.ToLower(date.AddDate(0, 0, 31).Format("2006/Jan")) + } + if date.Year() > firstEntryDate.Year() || date.Month() > firstEntryDate.Month() { + data.PrevMonth = date.AddDate(0, 0, -31).Format("January") + data.PrevMonthUrl = strings.ToLower(date.AddDate(0, 0, -31).Format("2006/Jan")) + } + data.CurrentDate = date template, _ := template.ParseFiles( "./web/templates/_layout/default.html.tmpl", diff --git a/internal/app/model/journal.go b/internal/app/model/journal.go index c2468fb..4d52b87 100644 --- a/internal/app/model/journal.go +++ b/internal/app/model/journal.go @@ -171,6 +171,16 @@ func (js *Journals) FetchAll() []Journal { return js.loadFromRows(rows) } +// FetchByDate Get all journal entries on a given date +func (js *Journals) FetchByDate(date string) []Journal { + rows, err := js.Container.Db.Query("SELECT * FROM `"+journalTable+"` WHERE `date` LIKE ? ORDER BY `id`", date+"%") + if err != nil { + return []Journal{} + } + + return js.loadFromRows(rows) +} + // FetchPaginated returns a set of paginated journal entries func (js *Journals) FetchPaginated(query database.PaginationQuery) ([]Journal, database.PaginationInformation) { pagination := database.PaginationInformation{ diff --git a/web/templates/calendar.html.tmpl b/web/templates/calendar.html.tmpl index 350f8d2..ef27e00 100644 --- a/web/templates/calendar.html.tmpl +++ b/web/templates/calendar.html.tmpl @@ -3,13 +3,22 @@
@@ -25,114 +34,22 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {{range .Weeks}} + + {{range .}} + {{if .IsEmpty}} + + {{else}} + + {{end}} + {{end}} + + {{end}}
    -

1

-
-

2

-
-

3

-
-

4

-
-

5

-
-

6

-
-

7

-
-

8

-
-

9

-
-

10

-
-

11

-
-

12

-
-

13

-
-

14

-
-

15

-
-

16

-
-

17

-
-

18

-
-

19

-
-

20

-
-

21

-
-

22

-
-

23

-
-

24

- Big Mouth -
-

25

-
-

26

-
-

27

-
-

28

-
-

29

-
-

30

-
-

31

-
 
  +

{{.Date.Day}}

+ {{range (index $.Days .Date.Day)}} + {{.Title}} + {{end}} +
diff --git a/web/themes/default/style.css b/web/themes/default/style.css index 23d9cfe..718d9e8 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -558,14 +558,14 @@ section.stats { margin: 0 auto 2rem; } -.calendar-top a:nth-child(2) { +.calendar-top .prev { left: 0; position: absolute; text-align: left; top: .5rem; } -.calendar-top a:nth-child(3) { +.calendar-top .next { position: absolute; right: 0; top: .5rem; @@ -579,6 +579,10 @@ section.stats { width: 100%; } +.calendar thead { + display: none; +} + .calendar th, .calendar td { border: 1px solid #dedede; @@ -593,11 +597,15 @@ section.stats { } .calendar td { - height: 3rem; - padding-top: 2.25rem; + display: block; + padding: 1.75rem 1rem .5rem; position: relative; } +.calendar td.empty { + display: none; +} + .calendar td h3 { font-size: 1.25rem; margin: 0; @@ -607,8 +615,20 @@ section.stats { top: .5rem; } -.calendar td a { - font-size: 1rem; - font-style: italic; - line-height: 1.25rem; -} \ No newline at end of file +@media screen and (min-width: 768px) { + .calendar thead { + display: table-header-group; + } + + .calendar td.empty, .calendar td { + display: table-cell; + height: 3rem; + padding: 1.75rem .5rem .5rem; + width: 14.27%; + } + + .calendar td a { + font-size: 1rem; + line-height: 1.25rem; + } +} From 79e306c566fac33c2f99faeff14a1a877dade0fd Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Mon, 27 Oct 2025 10:15:06 +0000 Subject: [PATCH 36/52] Calendar tests and fixes for bad requests and container issues --- .gitignore | 6 +- internal/app/controller/web/badrequest.go | 10 +- internal/app/controller/web/calendar.go | 4 +- internal/app/controller/web/calendar_test.go | 154 +++++++++++++++++++ internal/app/controller/web/edit.go | 4 +- internal/app/controller/web/view.go | 2 +- internal/app/model/journal_test.go | 27 ++++ web/templates/calendar.html.tmpl | 8 +- 8 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 internal/app/controller/web/calendar_test.go diff --git a/.gitignore b/.gitignore index b3ab3c6..e098088 100644 --- a/.gitignore +++ b/.gitignore @@ -29,11 +29,7 @@ data journal node_modules test/data/test.db +tests.xml .vscode .DS_Store .history -bootstrap -*.zip -*.pem -!test/cert.pem -!test/key.pem diff --git a/internal/app/controller/web/badrequest.go b/internal/app/controller/web/badrequest.go index bcd483c..00334b6 100644 --- a/internal/app/controller/web/badrequest.go +++ b/internal/app/controller/web/badrequest.go @@ -4,6 +4,7 @@ import ( "net/http" "text/template" + "github.com/jamiefdhurst/journal/internal/app" "github.com/jamiefdhurst/journal/pkg/controller" ) @@ -12,15 +13,22 @@ type BadRequest struct { controller.Super } +type badRequestTemplateData struct { + Container interface{} +} + // Run BadRequest func (c *BadRequest) Run(response http.ResponseWriter, request *http.Request) { + data := badRequestTemplateData{} + data.Container = c.Super.Container().(*app.Container) + response.WriteHeader(http.StatusNotFound) c.SaveSession(response) template, _ := template.ParseFiles( "./web/templates/_layout/default.html.tmpl", "./web/templates/error.html.tmpl") - template.ExecuteTemplate(response, "layout", c) + template.ExecuteTemplate(response, "layout", data) } // RunBadRequest calls the bad request from an existing controller diff --git a/internal/app/controller/web/calendar.go b/internal/app/controller/web/calendar.go index db788f0..cc2f798 100644 --- a/internal/app/controller/web/calendar.go +++ b/internal/app/controller/web/calendar.go @@ -1,6 +1,7 @@ package web import ( + "log" "net/http" "strconv" "strings" @@ -57,7 +58,8 @@ func (c *Calendar) Run(response http.ResponseWriter, request *http.Request) { date, err = time.Parse("2006-01-02", c.Params()[1]+"-01-01") } if err != nil { - RunBadRequest(response, request, c.Super.Container) + log.Print(err) + RunBadRequest(response, request, container) return } diff --git a/internal/app/controller/web/calendar_test.go b/internal/app/controller/web/calendar_test.go new file mode 100644 index 0000000..032fb75 --- /dev/null +++ b/internal/app/controller/web/calendar_test.go @@ -0,0 +1,154 @@ +package web + +import ( + "net/http" + "os" + "path" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" +) + +func init() { + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "../../../..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } +} + +func TestCalendarRun(t *testing.T) { + db := &database.MockSqlite{} + configuration := app.DefaultConfiguration() + container := &app.Container{Configuration: configuration, Db: db} + response := controller.NewMockResponse() + controller := &Calendar{} + controller.DisableTracking() + + // Test showing current year/month (only prev nav) + today := time.Now() + firstOfMonth := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, today.Location()) + daysInMonth := firstOfMonth.AddDate(0, 1, -1).Day() + db.EnableMultiMode() + db.AppendResult(&database.MockJournal_SingleRow{}) + for d := 2; d <= daysInMonth; d++ { + db.AppendResult(&database.MockRowsEmpty{}) + } + db.AppendResult(&database.MockJournal_SingleRow{}) + request, _ := http.NewRequest("GET", "/calendar", strings.NewReader("")) + controller.Init(container, []string{}, request) + controller.Run(response, request) + if !strings.Contains(response.Content, "Title") { + t.Error("Expected title of journal to be shown in calendar") + } + if !strings.Contains(response.Content, "class=\"prev prev-year\"") { + t.Error("Expected previous year link to be shown") + } + if !strings.Contains(response.Content, "class=\"prev prev-month\"") { + t.Error("Expected previous month link to be shown") + } + if strings.Contains(response.Content, "class=\"next next-year\"") { + t.Error("Expected next year link to be missing") + } + if strings.Contains(response.Content, "class=\"next next-month\"") { + t.Error("Expected next month link to be missing") + } + + // Test showing beginning (only next nav) + response.Reset() + db.EnableMultiMode() + db.AppendResult(&database.MockJournal_SingleRow{}) + for d := 2; d <= 28; d++ { + db.AppendResult(&database.MockRowsEmpty{}) + } + db.AppendResult(&database.MockJournal_SingleRow{}) + request, _ = http.NewRequest("GET", "/calendar/2018/feb", strings.NewReader("")) + controller.Init(container, []string{"", "2018", "feb"}, request) + controller.Run(response, request) + if !strings.Contains(response.Content, "Title") { + t.Error("Expected title of journal to be shown in calendar") + } + if !strings.Contains(response.Content, "

2018

") || !strings.Contains(response.Content, "

February2019

") || !strings.Contains(response.Content, "

January 0 { + t.Errorf("Expected empty result set returned when error received") + } + + // Test empty result + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + journals = js.FetchByDate("2001-01-01") + if len(journals) > 0 { + t.Errorf("Expected empty result set returned") + } + + // Test successful result + db.Rows = &database.MockJournal_MultipleRows{} + journals = js.FetchByDate("2001-01-01") + if len(journals) < 2 || journals[0].ID != 1 || journals[1].Content != "Content 2" { + t.Errorf("Expected 2 rows returned and with correct data") + } +} + func TestJournals_FetchPaginated(t *testing.T) { // Test error diff --git a/web/templates/calendar.html.tmpl b/web/templates/calendar.html.tmpl index ef27e00..3d100f8 100644 --- a/web/templates/calendar.html.tmpl +++ b/web/templates/calendar.html.tmpl @@ -5,19 +5,19 @@ From f25ee52a8bc9cc9b84dfaa3882a7ed3cacd79bc8 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Mon, 27 Oct 2025 10:52:56 +0000 Subject: [PATCH 37/52] Configurable excerpt through environment variables for index page --- README.md | 1 + internal/app/app.go | 6 ++++++ internal/app/controller/web/index.go | 5 +++++ internal/app/model/journal.go | 25 +++---------------------- internal/app/model/journal_test.go | 24 ++++++++++++------------ web/templates/index.html.tmpl | 2 +- 6 files changed, 28 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 2b817fb..c52f8e8 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The application uses environment variables to configure all aspects. * `J_DB_PATH` - Path to SQLite DB - default is `$GOPATH/data/journal.db` * `J_DESCRIPTION` - Set the HTML description of the Journal * `J_EDIT` - Set to `0` to disable article modification +* `J_EXCERPT_WORDS` - The length of the article shown as a preview/excerpt in the index, default `50` * `J_GA_CODE` - Google Analytics tag value, starts with `UA-`, or ignore to disable Google Analytics * `J_PORT` - Port to expose over HTTP, default is `3000` * `J_THEME` - Theme to use from within the _web/themes_ folder, defaults to `default` diff --git a/internal/app/app.go b/internal/app/app.go index 2e583bc..6fabe83 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -37,6 +37,7 @@ type Configuration struct { Description string EnableCreate bool EnableEdit bool + ExcerptWords int GoogleAnalyticsCode string Port string SSLCertificate string @@ -55,6 +56,7 @@ func DefaultConfiguration() Configuration { Description: "A private journal containing Jamie's innermost thoughts", EnableCreate: true, EnableEdit: true, + ExcerptWords: 50, GoogleAnalyticsCode: "", Port: "3000", SSLCertificate: "", @@ -88,6 +90,10 @@ func ApplyEnvConfiguration(config *Configuration) { if enableEdit == "0" { config.EnableEdit = false } + excerptWords, _ := strconv.Atoi(os.Getenv("J_EXCERPT_WORDS")) + if excerptWords > 0 { + config.ExcerptWords = excerptWords + } config.GoogleAnalyticsCode = os.Getenv("J_GA_CODE") port := os.Getenv("J_PORT") if port != "" { diff --git a/internal/app/controller/web/index.go b/internal/app/controller/web/index.go index e134ee4..052c967 100644 --- a/internal/app/controller/web/index.go +++ b/internal/app/controller/web/index.go @@ -18,6 +18,7 @@ type Index struct { type indexTemplateData struct { Container interface{} + Excerpt func(model.Journal) string Journals []model.Journal Pages []int Pagination database.PaginationDisplay @@ -49,6 +50,10 @@ func (c *Index) Run(response http.ResponseWriter, request *http.Request) { i++ } + data.Excerpt = func(j model.Journal) string { + return j.GetHTMLExcerpt(container.Configuration.ExcerptWords) + } + c.SaveSession(response) template, _ := template.ParseFiles( "./web/templates/_layout/default.html.tmpl", diff --git a/internal/app/model/journal.go b/internal/app/model/journal.go index 4d52b87..a13fb33 100644 --- a/internal/app/model/journal.go +++ b/internal/app/model/journal.go @@ -52,27 +52,8 @@ func (j Journal) GetEditableDate() string { return re.FindString(j.Date) } -// GetExcerpt returns a small extract of the entry as plain text -func (j Journal) GetExcerpt() string { - strip := regexp.MustCompile("\b+") - // Markdown handling - replace newlines with spaces - text := strings.ReplaceAll(j.Content, "\n", " ") - text = strip.ReplaceAllString(text, " ") - - // Clean up multiple spaces - spaceRegex := regexp.MustCompile(`\s+`) - text = spaceRegex.ReplaceAllString(text, " ") - - words := strings.Split(text, " ") - - if len(words) > 50 { - return strings.Join(words[:50], " ") + "..." - } - return strings.TrimSpace(strings.Join(words, " ")) -} - // GetHTMLExcerpt returns a small extract of the entry rendered as HTML -func (j Journal) GetHTMLExcerpt() string { +func (j Journal) GetHTMLExcerpt(maxWords int) string { if j.Content == "" { return "" } @@ -86,7 +67,7 @@ func (j Journal) GetHTMLExcerpt() string { for _, paragraph := range paragraphs { // Skip if we've already got 50+ words - if wordCount >= 50 { + if wordCount >= maxWords { break } @@ -98,7 +79,7 @@ func (j Journal) GetHTMLExcerpt() string { lineWords := strings.Fields(line) // Calculate how many words we can take from this line - wordsToTake := 50 - wordCount + wordsToTake := maxWords - wordCount if wordsToTake <= 0 { break } diff --git a/internal/app/model/journal_test.go b/internal/app/model/journal_test.go index 748dde6..11274d9 100644 --- a/internal/app/model/journal_test.go +++ b/internal/app/model/journal_test.go @@ -49,42 +49,42 @@ func TestJournal_GetEditableDate(t *testing.T) { } } -func TestJournal_GetExcerpt(t *testing.T) { +func TestJournal_GetHTMLExcerpt(t *testing.T) { tables := []struct { input string output string }{ - {"Some simple text", "Some simple text"}, - {"Multiple\n\nparagraphs, some with\n\nmultiple words", "Multiple paragraphs, some with multiple words"}, + {"Some **bold** text", "

Some bold text

\n"}, + {"Multiple\n\nparagraphs", "

Multiple

\n\n

paragraphs

\n"}, {"", ""}, - {"\n\n", ""}, - {"a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z", "a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x..."}, + {"*Italic* and **bold**", "

Italic and bold

\n"}, + {"Line 1\nLine 2\nLine 3", "

Line 1\nLine 2\nLine 3

\n"}, } for _, table := range tables { j := Journal{Content: table.input} - actual := j.GetExcerpt() + actual := j.GetHTMLExcerpt(50) if actual != table.output { - t.Errorf("Expected GetExcerpt() to produce result of '%s', got '%s'", table.output, actual) + t.Errorf("Expected GetHTMLExcerpt() to produce result of '%s', got '%s'", table.output, actual) } } } -func TestJournal_GetHTMLExcerpt(t *testing.T) { +func TestJournal_GetHTMLExcerpt_ShortWords(t *testing.T) { tables := []struct { input string output string }{ - {"Some **bold** text", "

Some bold text

\n"}, + {"Some **bold** text", "

Some bold

\n"}, {"Multiple\n\nparagraphs", "

Multiple

\n\n

paragraphs

\n"}, {"", ""}, - {"*Italic* and **bold**", "

Italic and bold

\n"}, - {"Line 1\nLine 2\nLine 3", "

Line 1\nLine 2\nLine 3

\n"}, + {"*Italic* and **bold**", "

Italic and…

\n"}, + {"Line 1\nLine 2\nLine 3", "

Line 1

\n"}, } for _, table := range tables { j := Journal{Content: table.input} - actual := j.GetHTMLExcerpt() + actual := j.GetHTMLExcerpt(2) if actual != table.output { t.Errorf("Expected GetHTMLExcerpt() to produce result of '%s', got '%s'", table.output, actual) } diff --git a/web/templates/index.html.tmpl b/web/templates/index.html.tmpl index 36d35c1..4229f72 100644 --- a/web/templates/index.html.tmpl +++ b/web/templates/index.html.tmpl @@ -13,7 +13,7 @@ {{.GetDate}}

- {{.GetHTMLExcerpt}} + {{call $.Excerpt . }} {{if $enableEdit}}Edit{{end}}

Read More

From df822a96a377f5a081cb97640721b57ebfd99f2c Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sat, 1 Nov 2025 21:14:18 +0000 Subject: [PATCH 38/52] Add configurable session and cookie settings --- README.md | 17 ++ internal/app/app.go | 56 ++++ internal/app/app_test.go | 357 ++++++++++++++++++++++ internal/app/controller/web/edit_test.go | 1 + internal/app/controller/web/index_test.go | 1 + internal/app/controller/web/new_test.go | 1 + pkg/controller/controller.go | 30 +- pkg/controller/controller_test.go | 61 +++- pkg/session/store.go | 49 ++- pkg/session/store_test.go | 334 ++++++++++++++++++++ 10 files changed, 883 insertions(+), 24 deletions(-) create mode 100644 internal/app/app_test.go create mode 100644 pkg/session/store_test.go diff --git a/README.md b/README.md index c52f8e8..88ae8b5 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ _Please note: you will need Docker installed on your local machine._ The application uses environment variables to configure all aspects. +### General Configuration + * `J_ARTICLES_PER_PAGE` - Articles to display per page, default `20` * `J_CREATE` - Set to `0` to disable article creation * `J_DB_PATH` - Path to SQLite DB - default is `$GOPATH/data/journal.db` @@ -59,6 +61,21 @@ The application uses environment variables to configure all aspects. * `J_THEME` - Theme to use from within the _web/themes_ folder, defaults to `default` * `J_TITLE` - Set the title of the Journal +### SSL/TLS Configuration + +* `J_SSL_CERT` - Path to SSL certificate file for HTTPS (enables SSL when set) +* `J_SSL_KEY` - Path to SSL private key file for HTTPS + +### Session and Cookie Security + +* `J_SESSION_KEY` - 32-byte encryption key for session data (AES-256). Must be exactly 32 printable ASCII characters. If not set, a random key is generated on startup (sessions won't persist across restarts). +* `J_SESSION_NAME` - Cookie name for sessions, default `journal-session` +* `J_COOKIE_DOMAIN` - Domain restriction for cookies, default is current domain only +* `J_COOKIE_MAX_AGE` - Cookie expiry time in seconds, default `2592000` (30 days) +* `J_COOKIE_HTTPONLY` - Set to `0` or `false` to allow JavaScript access to cookies (not recommended). Default is `true` for XSS protection. + +**Note:** When `J_SSL_CERT` is configured, session cookies automatically use the `Secure` flag to prevent transmission over unencrypted connections. + ## Layout The project layout follows the standard set out in the following document: diff --git a/internal/app/app.go b/internal/app/app.go index 6fabe83..afc543d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,7 +1,10 @@ package app import ( + "crypto/rand" "database/sql" + "encoding/hex" + "log" "os" "strconv" @@ -46,6 +49,12 @@ type Configuration struct { Theme string ThemePath string Title string + SessionKey string + SessionName string + CookieDomain string + CookieMaxAge int + CookieSecure bool + CookieHTTPOnly bool } // DefaultConfiguration returns the default settings for the app @@ -65,6 +74,12 @@ func DefaultConfiguration() Configuration { Theme: "default", ThemePath: "web/themes", Title: "Jamie's Journal", + SessionKey: "", + SessionName: "journal-session", + CookieDomain: "", + CookieMaxAge: 2592000, + CookieSecure: false, + CookieHTTPOnly: true, } } @@ -101,6 +116,47 @@ func ApplyEnvConfiguration(config *Configuration) { } config.SSLCertificate = os.Getenv("J_SSL_CERT") config.SSLKey = os.Getenv("J_SSL_KEY") + + sessionKey := os.Getenv("J_SESSION_KEY") + if sessionKey != "" { + if len(sessionKey) != 32 { + log.Println("WARNING: J_SESSION_KEY must be exactly 32 bytes. Using auto-generated key instead.") + sessionKey = "" + } + } + if sessionKey == "" { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err == nil { + sessionKey = hex.EncodeToString(bytes) + log.Println("WARNING: J_SESSION_KEY not set or invalid. Using auto-generated key. Sessions will not persist across restarts.") + } + } + config.SessionKey = sessionKey + + sessionName := os.Getenv("J_SESSION_NAME") + if sessionName != "" { + config.SessionName = sessionName + } + + cookieDomain := os.Getenv("J_COOKIE_DOMAIN") + if cookieDomain != "" { + config.CookieDomain = cookieDomain + } + + cookieMaxAge, _ := strconv.Atoi(os.Getenv("J_COOKIE_MAX_AGE")) + if cookieMaxAge > 0 { + config.CookieMaxAge = cookieMaxAge + } + + cookieHTTPOnly := os.Getenv("J_COOKIE_HTTPONLY") + if cookieHTTPOnly == "0" || cookieHTTPOnly == "false" { + config.CookieHTTPOnly = false + } + + if config.SSLCertificate != "" { + config.CookieSecure = true + } + staticPath := os.Getenv("J_STATIC_PATH") if staticPath != "" { config.StaticPath = staticPath diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..bccce5f --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,357 @@ +package app + +import ( + "os" + "testing" +) + +func TestDefaultConfiguration(t *testing.T) { + config := DefaultConfiguration() + + if config.ArticlesPerPage != 20 { + t.Errorf("Expected ArticlesPerPage 20, got %d", config.ArticlesPerPage) + } + if config.Port != "3000" { + t.Errorf("Expected Port '3000', got %q", config.Port) + } + if config.SessionName != "journal-session" { + t.Errorf("Expected SessionName 'journal-session', got %q", config.SessionName) + } + if config.CookieMaxAge != 2592000 { + t.Errorf("Expected CookieMaxAge 2592000, got %d", config.CookieMaxAge) + } + if config.CookieHTTPOnly != true { + t.Errorf("Expected CookieHTTPOnly true, got %v", config.CookieHTTPOnly) + } + if config.CookieSecure != false { + t.Errorf("Expected CookieSecure false, got %v", config.CookieSecure) + } + if config.SessionKey != "" { + t.Errorf("Expected SessionKey to be empty by default, got %q", config.SessionKey) + } +} + +func TestApplyEnvConfiguration_SessionKey(t *testing.T) { + tests := []struct { + name string + envValue string + expectWarning bool + expectKey bool + }{ + { + name: "Valid 32-byte key", + envValue: "12345678901234567890123456789012", + expectWarning: false, + expectKey: true, + }, + { + name: "Key too short generates auto key", + envValue: "tooshort", + expectWarning: true, + expectKey: true, + }, + { + name: "Key too long generates auto key", + envValue: "123456789012345678901234567890123", + expectWarning: true, + expectKey: true, + }, + { + name: "Empty key generates auto key", + envValue: "", + expectWarning: true, + expectKey: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + os.Setenv("J_SESSION_KEY", test.envValue) + defer os.Unsetenv("J_SESSION_KEY") + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if test.expectKey && config.SessionKey == "" { + t.Errorf("Expected session key to be set") + } + if test.expectKey && len(config.SessionKey) != 32 { + t.Errorf("Expected session key length 32, got %d", len(config.SessionKey)) + } + if test.envValue != "" && len(test.envValue) == 32 && config.SessionKey != test.envValue { + t.Errorf("Expected session key %q, got %q", test.envValue, config.SessionKey) + } + }) + } +} + +func TestApplyEnvConfiguration_SessionName(t *testing.T) { + tests := []struct { + name string + envValue string + expected string + }{ + { + name: "Custom session name", + envValue: "custom-session", + expected: "custom-session", + }, + { + name: "Empty uses default", + envValue: "", + expected: "journal-session", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_SESSION_NAME", test.envValue) + defer os.Unsetenv("J_SESSION_NAME") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.SessionName != test.expected { + t.Errorf("Expected SessionName %q, got %q", test.expected, config.SessionName) + } + }) + } +} + +func TestApplyEnvConfiguration_CookieDomain(t *testing.T) { + tests := []struct { + name string + envValue string + expected string + }{ + { + name: "Custom domain", + envValue: ".example.com", + expected: ".example.com", + }, + { + name: "Specific subdomain", + envValue: "app.example.com", + expected: "app.example.com", + }, + { + name: "Empty uses default", + envValue: "", + expected: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_COOKIE_DOMAIN", test.envValue) + defer os.Unsetenv("J_COOKIE_DOMAIN") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieDomain != test.expected { + t.Errorf("Expected CookieDomain %q, got %q", test.expected, config.CookieDomain) + } + }) + } +} + +func TestApplyEnvConfiguration_CookieMaxAge(t *testing.T) { + tests := []struct { + name string + envValue string + expected int + }{ + { + name: "Custom max age", + envValue: "7200", + expected: 7200, + }, + { + name: "One week", + envValue: "604800", + expected: 604800, + }, + { + name: "Invalid uses default", + envValue: "invalid", + expected: 2592000, + }, + { + name: "Empty uses default", + envValue: "", + expected: 2592000, + }, + { + name: "Zero uses default", + envValue: "0", + expected: 2592000, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_COOKIE_MAX_AGE", test.envValue) + defer os.Unsetenv("J_COOKIE_MAX_AGE") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieMaxAge != test.expected { + t.Errorf("Expected CookieMaxAge %d, got %d", test.expected, config.CookieMaxAge) + } + }) + } +} + +func TestApplyEnvConfiguration_CookieHTTPOnly(t *testing.T) { + tests := []struct { + name string + envValue string + expected bool + }{ + { + name: "Disabled with 0", + envValue: "0", + expected: false, + }, + { + name: "Disabled with false", + envValue: "false", + expected: false, + }, + { + name: "Enabled with 1", + envValue: "1", + expected: true, + }, + { + name: "Enabled with true", + envValue: "true", + expected: true, + }, + { + name: "Default is enabled", + envValue: "", + expected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_COOKIE_HTTPONLY", test.envValue) + defer os.Unsetenv("J_COOKIE_HTTPONLY") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieHTTPOnly != test.expected { + t.Errorf("Expected CookieHTTPOnly %v, got %v", test.expected, config.CookieHTTPOnly) + } + }) + } +} + +func TestApplyEnvConfiguration_CookieSecure(t *testing.T) { + tests := []struct { + name string + sslCert string + sslKey string + expected bool + description string + }{ + { + name: "Secure when SSL cert is set", + sslCert: "/path/to/cert.pem", + sslKey: "/path/to/key.pem", + expected: true, + description: "Cookie should be secure when SSL is enabled", + }, + { + name: "Not secure when SSL cert is empty", + sslCert: "", + sslKey: "", + expected: false, + description: "Cookie should not be secure when SSL is not enabled", + }, + { + name: "Secure even without key if cert is set", + sslCert: "/path/to/cert.pem", + sslKey: "", + expected: true, + description: "Cookie secure flag follows cert presence", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.sslCert != "" { + os.Setenv("J_SSL_CERT", test.sslCert) + defer os.Unsetenv("J_SSL_CERT") + } + if test.sslKey != "" { + os.Setenv("J_SSL_KEY", test.sslKey) + defer os.Unsetenv("J_SSL_KEY") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieSecure != test.expected { + t.Errorf("%s: Expected CookieSecure %v, got %v", test.description, test.expected, config.CookieSecure) + } + }) + } +} + +func TestApplyEnvConfiguration_Combined(t *testing.T) { + os.Setenv("J_SESSION_KEY", "abcdefghijklmnopqrstuvwxyz123456") + os.Setenv("J_SESSION_NAME", "my-app-session") + os.Setenv("J_COOKIE_DOMAIN", ".myapp.com") + os.Setenv("J_COOKIE_MAX_AGE", "1800") + os.Setenv("J_COOKIE_HTTPONLY", "0") + os.Setenv("J_SSL_CERT", "/path/to/cert.pem") + os.Setenv("J_PORT", "8080") + defer func() { + os.Unsetenv("J_SESSION_KEY") + os.Unsetenv("J_SESSION_NAME") + os.Unsetenv("J_COOKIE_DOMAIN") + os.Unsetenv("J_COOKIE_MAX_AGE") + os.Unsetenv("J_COOKIE_HTTPONLY") + os.Unsetenv("J_SSL_CERT") + os.Unsetenv("J_PORT") + }() + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.SessionKey != "abcdefghijklmnopqrstuvwxyz123456" { + t.Errorf("Expected SessionKey 'abcdefghijklmnopqrstuvwxyz123456', got %q", config.SessionKey) + } + if config.SessionName != "my-app-session" { + t.Errorf("Expected SessionName 'my-app-session', got %q", config.SessionName) + } + if config.CookieDomain != ".myapp.com" { + t.Errorf("Expected CookieDomain '.myapp.com', got %q", config.CookieDomain) + } + if config.CookieMaxAge != 1800 { + t.Errorf("Expected CookieMaxAge 1800, got %d", config.CookieMaxAge) + } + if config.CookieHTTPOnly != false { + t.Errorf("Expected CookieHTTPOnly false, got %v", config.CookieHTTPOnly) + } + if config.CookieSecure != true { + t.Errorf("Expected CookieSecure true (SSL enabled), got %v", config.CookieSecure) + } + if config.Port != "8080" { + t.Errorf("Expected Port '8080', got %q", config.Port) + } +} diff --git a/internal/app/controller/web/edit_test.go b/internal/app/controller/web/edit_test.go index 822a442..43f2ee5 100644 --- a/internal/app/controller/web/edit_test.go +++ b/internal/app/controller/web/edit_test.go @@ -26,6 +26,7 @@ func TestEdit_Run(t *testing.T) { db := &database.MockSqlite{} configuration := app.DefaultConfiguration() configuration.EnableEdit = true + configuration.SessionKey = "12345678901234567890123456789012" container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &Edit{} diff --git a/internal/app/controller/web/index_test.go b/internal/app/controller/web/index_test.go index a9fe16b..e9e2888 100644 --- a/internal/app/controller/web/index_test.go +++ b/internal/app/controller/web/index_test.go @@ -26,6 +26,7 @@ func TestIndex_Run(t *testing.T) { db := &database.MockSqlite{} configuration := app.DefaultConfiguration() configuration.ArticlesPerPage = 2 + configuration.SessionKey = "12345678901234567890123456789012" container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &Index{} diff --git a/internal/app/controller/web/new_test.go b/internal/app/controller/web/new_test.go index 888a1bb..28e670f 100644 --- a/internal/app/controller/web/new_test.go +++ b/internal/app/controller/web/new_test.go @@ -28,6 +28,7 @@ func TestNew_Run(t *testing.T) { db.Rows = &database.MockRowsEmpty{} configuration := app.DefaultConfiguration() configuration.EnableCreate = true + configuration.SessionKey = "12345678901234567890123456789012" container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() controller := &New{} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index c7882a6..a0085f1 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -3,7 +3,7 @@ package controller import ( "net/http" - "github.com/jamiefdhurst/journal/internal/app" + internalApp "github.com/jamiefdhurst/journal/internal/app" "github.com/jamiefdhurst/journal/internal/app/model" "github.com/jamiefdhurst/journal/pkg/session" ) @@ -35,8 +35,26 @@ func (c *Super) Init(app interface{}, params []string, request *http.Request) { c.container = app c.host = request.Host c.params = params - c.sessionStore = session.NewDefaultStore("defaultdefaultdefaultdefault1234") - c.session, _ = c.sessionStore.Get(request) + + appContainer, ok := app.(*internalApp.Container) + if ok && appContainer != nil { + store, err := session.NewDefaultStore(appContainer.Configuration.SessionKey, session.CookieConfig{ + Name: appContainer.Configuration.SessionName, + Domain: appContainer.Configuration.CookieDomain, + MaxAge: appContainer.Configuration.CookieMaxAge, + Secure: appContainer.Configuration.CookieSecure, + HTTPOnly: appContainer.Configuration.CookieHTTPOnly, + }) + if err == nil { + c.sessionStore = store + } + } + + if c.sessionStore != nil { + c.session, _ = c.sessionStore.Get(request) + } else { + c.session = session.NewSession() + } c.trackVisit(request) } @@ -59,7 +77,9 @@ func (c *Super) Params() []string { // SaveSession saves the session with the current response func (c *Super) SaveSession(w http.ResponseWriter) { - c.sessionStore.Save(w) + if c.sessionStore != nil { + c.sessionStore.Save(w) + } } // Session gets the private session value @@ -76,7 +96,7 @@ func (c *Super) trackVisit(request *http.Request) { return } - appContainer, ok := c.container.(*app.Container) + appContainer, ok := c.container.(*internalApp.Container) if !ok || appContainer.Db == nil { return } diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go index adcd9dd..3a24069 100644 --- a/pkg/controller/controller_test.go +++ b/pkg/controller/controller_test.go @@ -4,20 +4,59 @@ import ( "net/http" "strings" "testing" + + "github.com/jamiefdhurst/journal/internal/app" ) type BlankInterface struct{} func TestInit(t *testing.T) { - container := BlankInterface{} - params := []string{ - "param1", "param2", "param3", "param4", - } - controller := Super{} - request, _ := http.NewRequest("GET", "/", strings.NewReader("")) - request.Host = "foobar.com" - controller.Init(container, params, request) - if controller.Container() != container || controller.Params()[2] != "param3" || controller.Host() != "foobar.com" { - t.Error("Expected values were not passed into struct") - } + t.Run("Init with blank interface", func(t *testing.T) { + container := BlankInterface{} + params := []string{ + "param1", "param2", "param3", "param4", + } + controller := Super{} + request, _ := http.NewRequest("GET", "/", strings.NewReader("")) + request.Host = "foobar.com" + controller.Init(container, params, request) + if controller.Container() != container || controller.Params()[2] != "param3" || controller.Host() != "foobar.com" { + t.Error("Expected values were not passed into struct") + } + }) + + t.Run("Init with app container and session config", func(t *testing.T) { + container := &app.Container{ + Configuration: app.Configuration{ + SessionKey: "12345678901234567890123456789012", + SessionName: "test-session", + CookieDomain: "example.com", + CookieMaxAge: 3600, + CookieSecure: true, + CookieHTTPOnly: true, + }, + } + params := []string{"param1", "param2"} + controller := Super{} + request, _ := http.NewRequest("GET", "/", strings.NewReader("")) + request.Host = "test.com" + + controller.Init(container, params, request) + + if controller.Container() != container { + t.Error("Expected container to be set") + } + if controller.Host() != "test.com" { + t.Error("Expected host to be set") + } + if len(controller.Params()) != 2 { + t.Error("Expected params to be set") + } + if controller.sessionStore == nil { + t.Error("Expected session store to be initialized") + } + if controller.session == nil { + t.Error("Expected session to be initialized") + } + }) } diff --git a/pkg/session/store.go b/pkg/session/store.go index c679ac1..f9d9460 100644 --- a/pkg/session/store.go +++ b/pkg/session/store.go @@ -12,6 +12,7 @@ import ( "net/http" ) +// Store defines the interface for session storage implementations type Store interface { Get(r *http.Request) (*Session, error) Save(w http.ResponseWriter) error @@ -19,19 +20,50 @@ type Store interface { const defaultName string = "journal-session" +// CookieConfig defines the configuration for session cookies +type CookieConfig struct { + Name string + Domain string + MaxAge int + Secure bool + HTTPOnly bool +} + +// DefaultStore implements Store using encrypted cookies for session storage type DefaultStore struct { cachedSession *Session key []byte name string + config CookieConfig } -func NewDefaultStore(key string) *DefaultStore { - return &DefaultStore{ - key: []byte(key), - name: defaultName, +// NewDefaultStore creates a new DefaultStore with the given encryption key and cookie configuration. +// The key must be exactly 32 bytes (for AES-256) and contain only printable ASCII characters. +func NewDefaultStore(key string, config CookieConfig) (*DefaultStore, error) { + if len(key) != 32 { + return nil, errors.New("session key must be exactly 32 bytes") + } + + for i := 0; i < len(key); i++ { + if key[i] < 32 || key[i] > 126 { + return nil, errors.New("session key must contain only printable ASCII characters") + } + } + + name := config.Name + if name == "" { + name = defaultName } + + return &DefaultStore{ + key: []byte(key), + name: name, + config: config, + }, nil } +// Get retrieves the session from the request cookie, decrypting and deserializing it. +// If no session exists, a new empty session is created. func (s *DefaultStore) Get(r *http.Request) (*Session, error) { var err error if s.cachedSession == nil { @@ -50,6 +82,7 @@ func (s *DefaultStore) Get(r *http.Request) (*Session, error) { return s.cachedSession, err } +// Save encrypts and serializes the session, writing it to a cookie in the response. func (s *DefaultStore) Save(w http.ResponseWriter) error { encrypted, err := s.encrypt(s.cachedSession.Values) if err != nil { @@ -60,11 +93,11 @@ func (s *DefaultStore) Save(w http.ResponseWriter) error { Name: s.name, Value: encrypted, Path: "/", - Domain: "", - MaxAge: 86400 * 30, - Secure: false, + Domain: s.config.Domain, + MaxAge: s.config.MaxAge, + Secure: s.config.Secure, SameSite: http.SameSiteStrictMode, - HttpOnly: false, + HttpOnly: s.config.HTTPOnly, }) return nil diff --git a/pkg/session/store_test.go b/pkg/session/store_test.go new file mode 100644 index 0000000..6c5c227 --- /dev/null +++ b/pkg/session/store_test.go @@ -0,0 +1,334 @@ +package session + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewDefaultStore(t *testing.T) { + tests := []struct { + name string + key string + config CookieConfig + expectError bool + errorMsg string + }{ + { + name: "Valid 32-byte key", + key: "12345678901234567890123456789012", + config: CookieConfig{ + Name: "test-session", + Domain: "example.com", + MaxAge: 3600, + Secure: true, + HTTPOnly: true, + }, + expectError: false, + }, + { + name: "Key too short", + key: "tooshort", + config: CookieConfig{ + Name: "test-session", + }, + expectError: true, + errorMsg: "session key must be exactly 32 bytes", + }, + { + name: "Key too long", + key: "123456789012345678901234567890123", + config: CookieConfig{ + Name: "test-session", + }, + expectError: true, + errorMsg: "session key must be exactly 32 bytes", + }, + { + name: "Invalid characters in key", + key: "123456789012345678901234\x00\x01\x02\x03\x04\x05\x06\x07", + config: CookieConfig{ + Name: "test-session", + }, + expectError: true, + errorMsg: "session key must contain only printable ASCII characters", + }, + { + name: "Default cookie name when empty", + key: "12345678901234567890123456789012", + config: CookieConfig{ + Name: "", + }, + expectError: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store, err := NewDefaultStore(test.key, test.config) + + if test.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if err.Error() != test.errorMsg { + t.Errorf("Expected error %q, got %q", test.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + } + if store == nil { + t.Errorf("Expected store to be created but got nil") + } + if test.config.Name == "" && store.name != "journal-session" { + t.Errorf("Expected default name 'journal-session', got %q", store.name) + } + if test.config.Name != "" && store.name != test.config.Name { + t.Errorf("Expected name %q, got %q", test.config.Name, store.name) + } + } + }) + } +} + +func TestEncryptDecryptCycle(t *testing.T) { + key := "12345678901234567890123456789012" + config := CookieConfig{ + Name: "test-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: true, + } + + store, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + testData := map[string]interface{}{ + "user_id": "12345", + "name": "Test User", + "count": 42, + "active": true, + } + + encrypted, err := store.encrypt(testData) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + + if encrypted == "" { + t.Errorf("Encrypted string should not be empty") + } + + var decrypted map[string]interface{} + err = store.decrypt(encrypted, &decrypted) + if err != nil { + t.Fatalf("Failed to decrypt: %v", err) + } + + if decrypted["user_id"] != testData["user_id"] { + t.Errorf("Expected user_id %v, got %v", testData["user_id"], decrypted["user_id"]) + } + if decrypted["name"] != testData["name"] { + t.Errorf("Expected name %v, got %v", testData["name"], decrypted["name"]) + } +} + +func TestCookieConfiguration(t *testing.T) { + tests := []struct { + name string + config CookieConfig + }{ + { + name: "Secure cookie with HTTPOnly", + config: CookieConfig{ + Name: "secure-session", + Domain: "example.com", + MaxAge: 7200, + Secure: true, + HTTPOnly: true, + }, + }, + { + name: "Non-secure cookie without HTTPOnly", + config: CookieConfig{ + Name: "insecure-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: false, + }, + }, + { + name: "Custom domain cookie", + config: CookieConfig{ + Name: "domain-session", + Domain: "example.com", + MaxAge: 1800, + Secure: true, + HTTPOnly: true, + }, + }, + { + name: "Long expiry cookie", + config: CookieConfig{ + Name: "long-session", + Domain: "", + MaxAge: 2592000, + Secure: false, + HTTPOnly: true, + }, + }, + } + + key := "12345678901234567890123456789012" + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store, err := NewDefaultStore(key, test.config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + session := NewSession() + session.Set("test", "value") + store.cachedSession = session + + w := httptest.NewRecorder() + err = store.Save(w) + if err != nil { + t.Fatalf("Failed to save session: %v", err) + } + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("Expected 1 cookie, got %d", len(cookies)) + } + + cookie := cookies[0] + + if cookie.Name != test.config.Name { + t.Errorf("Expected cookie name %q, got %q", test.config.Name, cookie.Name) + } + if cookie.Domain != test.config.Domain { + t.Errorf("Expected cookie domain %q, got %q", test.config.Domain, cookie.Domain) + } + if cookie.MaxAge != test.config.MaxAge { + t.Errorf("Expected cookie MaxAge %d, got %d", test.config.MaxAge, cookie.MaxAge) + } + if cookie.Secure != test.config.Secure { + t.Errorf("Expected cookie Secure %v, got %v", test.config.Secure, cookie.Secure) + } + if cookie.HttpOnly != test.config.HTTPOnly { + t.Errorf("Expected cookie HttpOnly %v, got %v", test.config.HTTPOnly, cookie.HttpOnly) + } + if cookie.Path != "/" { + t.Errorf("Expected cookie Path '/', got %q", cookie.Path) + } + if cookie.SameSite != http.SameSiteStrictMode { + t.Errorf("Expected cookie SameSite Strict, got %v", cookie.SameSite) + } + }) + } +} + +func TestGetSession(t *testing.T) { + key := "12345678901234567890123456789012" + config := CookieConfig{ + Name: "test-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: true, + } + + store, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + t.Run("Get session without cookie", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + session, err := store.Get(req) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if session == nil { + t.Errorf("Expected session to be created") + } + }) + + t.Run("Get session with valid cookie", func(t *testing.T) { + session := NewSession() + session.Set("user", "testuser") + store.cachedSession = session + + w := httptest.NewRecorder() + err := store.Save(w) + if err != nil { + t.Fatalf("Failed to save session: %v", err) + } + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("Expected 1 cookie, got %d", len(cookies)) + } + + newStore, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create new store: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(cookies[0]) + + retrievedSession, err := newStore.Get(req) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if retrievedSession == nil { + t.Fatalf("Expected session to be retrieved") + } + + user := retrievedSession.Get("user") + if user == nil { + t.Errorf("Expected 'user' key to exist in session") + } + if user != "testuser" { + t.Errorf("Expected user 'testuser', got %v", user) + } + }) +} + +func TestSessionCaching(t *testing.T) { + key := "12345678901234567890123456789012" + config := CookieConfig{ + Name: "test-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: true, + } + + store, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + session1, err := store.Get(req) + if err != nil { + t.Fatalf("Failed to get session: %v", err) + } + + session2, err := store.Get(req) + if err != nil { + t.Fatalf("Failed to get session second time: %v", err) + } + + if session1 != session2 { + t.Errorf("Expected same session instance to be returned (cached)") + } +} From bc26511888377987afb899d7b63c9b485c3be269 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sat, 1 Nov 2025 21:23:36 +0000 Subject: [PATCH 39/52] Add support for .env files --- .gitignore | 1 + README.md | 3 + internal/app/app.go | 52 +++++++----- internal/app/app_test.go | 100 ++++++++++++++++++++++ pkg/env/parser.go | 63 ++++++++++++++ pkg/env/parser_test.go | 177 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 377 insertions(+), 19 deletions(-) create mode 100644 pkg/env/parser.go create mode 100644 pkg/env/parser_test.go diff --git a/.gitignore b/.gitignore index e098088..9ba87d3 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ tests.xml .vscode .DS_Store .history +.env diff --git a/README.md b/README.md index 88ae8b5..e296dd1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ _Please note: you will need Docker installed on your local machine._ The application uses environment variables to configure all aspects. +You can optionally supply these through a `.env` file that will be parsed before +any additional environment variables. + ### General Configuration * `J_ARTICLES_PER_PAGE` - Articles to display per page, default `20` diff --git a/internal/app/app.go b/internal/app/app.go index afc543d..acff1d5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,6 +9,7 @@ import ( "strconv" "github.com/jamiefdhurst/journal/pkg/database/rows" + "github.com/jamiefdhurst/journal/pkg/env" ) // Database Define same interface as database @@ -84,40 +85,53 @@ func DefaultConfiguration() Configuration { } // ApplyEnvConfiguration applies the env variables on top of existing config +// It first loads values from a .env file (if it exists), then applies any +// environment variables set in the system (which override .env values) func ApplyEnvConfiguration(config *Configuration) { - articles, _ := strconv.Atoi(os.Getenv("J_ARTICLES_PER_PAGE")) + // Parse .env file (if it exists) + dotenvVars, _ := env.Parse(".env") + + // Helper function to get env var, preferring system env over .env file + getEnv := func(key string) string { + if val := os.Getenv(key); val != "" { + return val + } + return dotenvVars[key] + } + + articles, _ := strconv.Atoi(getEnv("J_ARTICLES_PER_PAGE")) if articles > 0 { config.ArticlesPerPage = articles } - database := os.Getenv("J_DB_PATH") + database := getEnv("J_DB_PATH") if database != "" { config.DatabasePath = database } - description := os.Getenv("J_DESCRIPTION") + description := getEnv("J_DESCRIPTION") if description != "" { config.Description = description } - enableCreate := os.Getenv("J_CREATE") + enableCreate := getEnv("J_CREATE") if enableCreate == "0" { config.EnableCreate = false } - enableEdit := os.Getenv("J_EDIT") + enableEdit := getEnv("J_EDIT") if enableEdit == "0" { config.EnableEdit = false } - excerptWords, _ := strconv.Atoi(os.Getenv("J_EXCERPT_WORDS")) + excerptWords, _ := strconv.Atoi(getEnv("J_EXCERPT_WORDS")) if excerptWords > 0 { config.ExcerptWords = excerptWords } - config.GoogleAnalyticsCode = os.Getenv("J_GA_CODE") - port := os.Getenv("J_PORT") + config.GoogleAnalyticsCode = getEnv("J_GA_CODE") + port := getEnv("J_PORT") if port != "" { config.Port = port } - config.SSLCertificate = os.Getenv("J_SSL_CERT") - config.SSLKey = os.Getenv("J_SSL_KEY") + config.SSLCertificate = getEnv("J_SSL_CERT") + config.SSLKey = getEnv("J_SSL_KEY") - sessionKey := os.Getenv("J_SESSION_KEY") + sessionKey := getEnv("J_SESSION_KEY") if sessionKey != "" { if len(sessionKey) != 32 { log.Println("WARNING: J_SESSION_KEY must be exactly 32 bytes. Using auto-generated key instead.") @@ -133,22 +147,22 @@ func ApplyEnvConfiguration(config *Configuration) { } config.SessionKey = sessionKey - sessionName := os.Getenv("J_SESSION_NAME") + sessionName := getEnv("J_SESSION_NAME") if sessionName != "" { config.SessionName = sessionName } - cookieDomain := os.Getenv("J_COOKIE_DOMAIN") + cookieDomain := getEnv("J_COOKIE_DOMAIN") if cookieDomain != "" { config.CookieDomain = cookieDomain } - cookieMaxAge, _ := strconv.Atoi(os.Getenv("J_COOKIE_MAX_AGE")) + cookieMaxAge, _ := strconv.Atoi(getEnv("J_COOKIE_MAX_AGE")) if cookieMaxAge > 0 { config.CookieMaxAge = cookieMaxAge } - cookieHTTPOnly := os.Getenv("J_COOKIE_HTTPONLY") + cookieHTTPOnly := getEnv("J_COOKIE_HTTPONLY") if cookieHTTPOnly == "0" || cookieHTTPOnly == "false" { config.CookieHTTPOnly = false } @@ -157,19 +171,19 @@ func ApplyEnvConfiguration(config *Configuration) { config.CookieSecure = true } - staticPath := os.Getenv("J_STATIC_PATH") + staticPath := getEnv("J_STATIC_PATH") if staticPath != "" { config.StaticPath = staticPath } - theme := os.Getenv("J_THEME") + theme := getEnv("J_THEME") if theme != "" { config.Theme = theme } - themePath := os.Getenv("J_THEME_PATH") + themePath := getEnv("J_THEME_PATH") if themePath != "" { config.ThemePath = themePath } - title := os.Getenv("J_TITLE") + title := getEnv("J_TITLE") if title != "" { config.Title = title } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index bccce5f..b0835ac 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2,6 +2,7 @@ package app import ( "os" + "path/filepath" "testing" ) @@ -355,3 +356,102 @@ func TestApplyEnvConfiguration_Combined(t *testing.T) { t.Errorf("Expected Port '8080', got %q", config.Port) } } + +func TestApplyEnvConfiguration_DotEnvFile(t *testing.T) { + // Save current working directory + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a temporary directory for testing + tmpDir := t.TempDir() + os.Chdir(tmpDir) + + // Create a .env file + envContent := `J_PORT=9000 +J_TITLE=Test Journal +J_DESCRIPTION=A test journal +J_ARTICLES_PER_PAGE=15 +J_COOKIE_MAX_AGE=3600 +` + if err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644); err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.Port != "9000" { + t.Errorf("Expected Port '9000' from .env, got %q", config.Port) + } + if config.Title != "Test Journal" { + t.Errorf("Expected Title 'Test Journal' from .env, got %q", config.Title) + } + if config.Description != "A test journal" { + t.Errorf("Expected Description 'A test journal' from .env, got %q", config.Description) + } + if config.ArticlesPerPage != 15 { + t.Errorf("Expected ArticlesPerPage 15 from .env, got %d", config.ArticlesPerPage) + } + if config.CookieMaxAge != 3600 { + t.Errorf("Expected CookieMaxAge 3600 from .env, got %d", config.CookieMaxAge) + } +} + +func TestApplyEnvConfiguration_EnvOverridesDotEnv(t *testing.T) { + // Save current working directory and environment + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + defer os.Unsetenv("J_PORT") + defer os.Unsetenv("J_TITLE") + + // Create a temporary directory for testing + tmpDir := t.TempDir() + os.Chdir(tmpDir) + + // Create a .env file + envContent := `J_PORT=9000 +J_TITLE=DotEnv Title +J_DESCRIPTION=DotEnv Description +` + if err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644); err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + // Set environment variables that should override .env + os.Setenv("J_PORT", "7777") + os.Setenv("J_TITLE", "Override Title") + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + // Environment variables should override .env values + if config.Port != "7777" { + t.Errorf("Expected Port '7777' from env var (not .env), got %q", config.Port) + } + if config.Title != "Override Title" { + t.Errorf("Expected Title 'Override Title' from env var (not .env), got %q", config.Title) + } + // Values not overridden should come from .env + if config.Description != "DotEnv Description" { + t.Errorf("Expected Description 'DotEnv Description' from .env, got %q", config.Description) + } +} + +func TestApplyEnvConfiguration_NoDotEnvFile(t *testing.T) { + // Save current working directory + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a temporary directory without .env file + tmpDir := t.TempDir() + os.Chdir(tmpDir) + + // Should work fine even without .env file + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + // Should have default values + if config.Port != "3000" { + t.Errorf("Expected default Port '3000', got %q", config.Port) + } +} diff --git a/pkg/env/parser.go b/pkg/env/parser.go new file mode 100644 index 0000000..8ed3c7e --- /dev/null +++ b/pkg/env/parser.go @@ -0,0 +1,63 @@ +package env + +import ( + "bufio" + "os" + "strings" +) + +// Parse reads a .env file and returns a map of key-value pairs +// It does not modify the actual environment variables +func Parse(filepath string) (map[string]string, error) { + result := make(map[string]string) + + file, err := os.Open(filepath) + if err != nil { + // If file doesn't exist, return empty map (not an error) + if os.IsNotExist(err) { + return result, nil + } + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Split on first = sign + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove quotes if present + value = unquote(value) + + result[key] = value + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return result, nil +} + +// unquote removes surrounding quotes from a string +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} diff --git a/pkg/env/parser_test.go b/pkg/env/parser_test.go new file mode 100644 index 0000000..1185acd --- /dev/null +++ b/pkg/env/parser_test.go @@ -0,0 +1,177 @@ +package env + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + content string + expected map[string]string + }{ + { + name: "basic key-value pairs", + content: `KEY1=value1 +KEY2=value2 +KEY3=value3`, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + "KEY3": "value3", + }, + }, + { + name: "with comments", + content: `# This is a comment +KEY1=value1 +# Another comment +KEY2=value2`, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + }, + { + name: "with empty lines", + content: `KEY1=value1 + +KEY2=value2 + +`, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + }, + { + name: "with quoted values", + content: `KEY1="value with spaces" +KEY2='single quoted value' +KEY3=unquoted`, + expected: map[string]string{ + "KEY1": "value with spaces", + "KEY2": "single quoted value", + "KEY3": "unquoted", + }, + }, + { + name: "with spaces around equals", + content: `KEY1 = value1 +KEY2= value2 +KEY3 =value3`, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + "KEY3": "value3", + }, + }, + { + name: "with equals in value", + content: `KEY1=value=with=equals +KEY2=http://example.com?param=value`, + expected: map[string]string{ + "KEY1": "value=with=equals", + "KEY2": "http://example.com?param=value", + }, + }, + { + name: "malformed lines are skipped", + content: `KEY1=value1 +INVALID_LINE_NO_EQUALS +KEY2=value2`, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + }, + { + name: "empty file", + content: "", + expected: map[string]string{}, + }, + { + name: "only comments and empty lines", + content: `# Comment 1 +# Comment 2 + +# Comment 3`, + expected: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary .env file + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".env") + + if err := os.WriteFile(envFile, []byte(tt.content), 0644); err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + + // Parse the file + result, err := Parse(envFile) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Check the results + if len(result) != len(tt.expected) { + t.Errorf("Expected %d entries, got %d", len(tt.expected), len(result)) + } + + for key, expectedValue := range tt.expected { + if actualValue, ok := result[key]; !ok { + t.Errorf("Missing key %q", key) + } else if actualValue != expectedValue { + t.Errorf("For key %q: expected %q, got %q", key, expectedValue, actualValue) + } + } + + for key := range result { + if _, ok := tt.expected[key]; !ok { + t.Errorf("Unexpected key %q with value %q", key, result[key]) + } + } + }) + } +} + +func TestParseNonExistentFile(t *testing.T) { + // Parsing a non-existent file should return an empty map, not an error + result, err := Parse("/nonexistent/path/.env") + if err != nil { + t.Errorf("Parse() should not error on non-existent file, got: %v", err) + } + if len(result) != 0 { + t.Errorf("Expected empty map, got %d entries", len(result)) + } +} + +func TestUnquote(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {`"double quoted"`, "double quoted"}, + {`'single quoted'`, "single quoted"}, + {`unquoted`, "unquoted"}, + {`"`, `"`}, + {`''`, ``}, + {`""`, ``}, + {`"mismatched'`, `"mismatched'`}, + {`'mismatched"`, `'mismatched"`}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := unquote(tt.input) + if result != tt.expected { + t.Errorf("unquote(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} From 54d14ea78106b17c2b4cf7768cf12afb4b47c6f1 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Wed, 10 Dec 2025 20:36:19 +0000 Subject: [PATCH 40/52] 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 41/52] 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 42/52] 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") From aa97f61682d645c594867de669b70ee3454c3d52 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Wed, 17 Dec 2025 20:27:08 +0000 Subject: [PATCH 43/52] Fix API inconsistencies and session decrypt issue --- api/README.md | 10 +++++----- internal/app/controller/apiv1/create.go | 2 +- internal/app/controller/apiv1/data.go | 2 +- internal/app/controller/apiv1/stats.go | 2 +- internal/app/controller/apiv1/update.go | 2 +- internal/app/controller/web/view_test.go | 9 +++------ internal/app/model/visit.go | 2 +- journal_test.go | 12 ++++++------ pkg/session/store.go | 2 ++ test/mocks/database/database.go | 6 ++++-- web/static/openapi.yml | 15 ++++++++------- 11 files changed, 33 insertions(+), 31 deletions(-) diff --git a/api/README.md b/api/README.md index 2f65266..a38348d 100644 --- a/api/README.md +++ b/api/README.md @@ -50,7 +50,7 @@ information on the total posts, pages and posts per page. { "url": "/api/v1/post/example-post", "title": "An Example Post", - "date": "2018-05-18T00:00:00Z", + "date": "2018-05-18", "content": "TEST", "created_at": "2018-05-18T15:16:17Z", "updated_at": "2018-05-18T15:16:17Z" @@ -79,7 +79,7 @@ Contains the single post. { "url": "/api/v1/post/example-post", "title": "An Example Post", - "date": "2018-05-18T00:00:00Z", + "date": "2018-05-18", "content": "TEST", "created_at": "2018-05-18T15:16:17Z", "updated_at": "2018-05-18T15:16:17Z" @@ -117,7 +117,7 @@ The date can be provided in the following formats: { "url": "/api/v1/post/a-brand-new-post", "title": "A Brand New Post", - "date": "2018-06-28T00:42:12Z", + "date": "2018-06-28", "content": "This is a brand new post, completely." } ``` @@ -141,7 +141,7 @@ Contains a randomly selected post. { "url": "/api/v1/post/example-post", "title": "An Example Post", - "date": "2018-05-18T12:53:22Z", + "date": "2018-05-18", "content": "TEST" } ``` @@ -187,7 +187,7 @@ When updating the post, the slug remains constant, even when the title changes. { "url": "/api/v1/post/a-brand-new-post", "title": "Even Braver New World", - "date": "2018-06-21T09:12:00Z", + "date": "2018-06-21", "content": "I changed a bit more on this attempt." } ``` diff --git a/internal/app/controller/apiv1/create.go b/internal/app/controller/apiv1/create.go index 101733e..bfea912 100644 --- a/internal/app/controller/apiv1/create.go +++ b/internal/app/controller/apiv1/create.go @@ -37,7 +37,7 @@ func (c *Create) Run(response http.ResponseWriter, request *http.Request) { response.WriteHeader(http.StatusCreated) encoder := json.NewEncoder(response) encoder.SetEscapeHTML(false) - encoder.Encode(journal) + encoder.Encode(MapJournalToJSON(journal)) } } } diff --git a/internal/app/controller/apiv1/data.go b/internal/app/controller/apiv1/data.go index db3d91c..58fc1e9 100644 --- a/internal/app/controller/apiv1/data.go +++ b/internal/app/controller/apiv1/data.go @@ -21,7 +21,7 @@ func MapJournalToJSON(journal model.Journal) journalToJSON { result := journalToJSON{ URL: "/api/v1/post/" + journal.Slug, Title: journal.Title, - Date: journal.Date, + Date: journal.GetEditableDate(), Content: journal.Content, } diff --git a/internal/app/controller/apiv1/stats.go b/internal/app/controller/apiv1/stats.go index c473ef6..07ca5b3 100644 --- a/internal/app/controller/apiv1/stats.go +++ b/internal/app/controller/apiv1/stats.go @@ -52,7 +52,7 @@ func (c *Stats) Run(response http.ResponseWriter, request *http.Request) { if stats.Posts.Count > 0 { firstPost := allJournals[stats.Posts.Count-1] - stats.Posts.FirstPostDate = firstPost.GetDate() + stats.Posts.FirstPostDate = firstPost.GetEditableDate() } stats.Configuration.Title = container.Configuration.Title diff --git a/internal/app/controller/apiv1/update.go b/internal/app/controller/apiv1/update.go index 9bf0c6f..26f6bd3 100644 --- a/internal/app/controller/apiv1/update.go +++ b/internal/app/controller/apiv1/update.go @@ -51,7 +51,7 @@ func (c *Update) Run(response http.ResponseWriter, request *http.Request) { journal = js.Save(journal) encoder := json.NewEncoder(response) encoder.SetEscapeHTML(false) - encoder.Encode(journal) + encoder.Encode(MapJournalToJSON(journal)) } } } diff --git a/internal/app/controller/web/view_test.go b/internal/app/controller/web/view_test.go index 3151099..9596db8 100644 --- a/internal/app/controller/web/view_test.go +++ b/internal/app/controller/web/view_test.go @@ -63,7 +63,7 @@ func TestView_Run(t *testing.T) { t.Error("Expected previous and next links to be shown in page") } - // Test that timestamp metadata section is NOT displayed when timestamps are nil + // Test that timestamp labels are displayed when timestamps are present response.Reset() request, _ = http.NewRequest("GET", "/slug", strings.NewReader("")) // Reset database to single mode @@ -72,10 +72,7 @@ func TestView_Run(t *testing.T) { 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") + if !strings.Contains(response.Content, "Created:") || !strings.Contains(response.Content, "Last Updated:") { + t.Error("Expected timestamp labels to be displayed when timestamps are present") } } diff --git a/internal/app/model/visit.go b/internal/app/model/visit.go index e9921cd..d0e4950 100644 --- a/internal/app/model/visit.go +++ b/internal/app/model/visit.go @@ -110,7 +110,7 @@ func (vs *Visits) GetDailyStats(days int) []DailyVisit { query := ` SELECT - date, + DATE(date), COALESCE(SUM(CASE WHEN url LIKE '/api/%' THEN hits ELSE 0 END), 0) as api_hits, COALESCE(SUM(CASE WHEN url NOT LIKE '/api/%' THEN hits ELSE 0 END), 0) as web_hits, COALESCE(SUM(hits), 0) as total diff --git a/journal_test.go b/journal_test.go index 479e4a0..1224258 100644 --- a/journal_test.go +++ b/journal_test.go @@ -96,7 +96,7 @@ func TestApiv1List(t *testing.T) { defer res.Body.Close() body, _ := io.ReadAll(res.Body) - expected := `{"links":{},"pagination":{"current_page":1,"total_pages":1,"posts_per_page":20,"total_posts":3},"posts":[{"url":"/api/v1/post/test-3","title":"A Final Test","date":"2018-03-01T00:00:00Z","content":"

Test finally!

"},{"url":"/api/v1/post/test-2","title":"Another Test","date":"2018-02-01T00:00:00Z","content":"

Test again!

"},{"url":"/api/v1/post/test","title":"Test","date":"2018-01-01T00:00:00Z","content":"

Test!

"}]}` + expected := `{"links":{},"pagination":{"current_page":1,"total_pages":1,"posts_per_page":20,"total_posts":3},"posts":[{"url":"/api/v1/post/test-3","title":"A Final Test","date":"2018-03-01","content":"

Test finally!

"},{"url":"/api/v1/post/test-2","title":"Another Test","date":"2018-02-01","content":"

Test again!

"},{"url":"/api/v1/post/test","title":"Test","date":"2018-01-01","content":"

Test!

"}]}` // Use contains to get rid of any extra whitespace that we can discount if !strings.Contains(string(body[:]), expected) { @@ -122,7 +122,7 @@ func TestApiV1Single(t *testing.T) { defer res.Body.Close() body, _ := io.ReadAll(res.Body) - expected := `{"url":"/api/v1/post/test","title":"Test","date":"2018-01-01T00:00:00Z","content":"

Test!

"}` + expected := `{"url":"/api/v1/post/test","title":"Test","date":"2018-01-01","content":"

Test!

"}` // Use contains to get rid of any extra whitespace that we can discount if !strings.Contains(string(body[:]), expected) { @@ -191,7 +191,7 @@ func TestApiV1Create(t *testing.T) { bodyStr := 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"`} + expectedFields := []string{`"url":"/api/v1/post/test-4"`, `"title":"Test 4"`, `"date":"2018-06-01"`, `"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) @@ -265,7 +265,7 @@ func TestApiV1Create_RepeatTitles(t *testing.T) { bodyStr := 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"`} + expectedFields := []string{`"url":"/api/v1/post/repeated-1"`, `"title":"Repeated"`, `"date":"2019-02-01"`, `"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) @@ -293,7 +293,7 @@ func TestApiV1Update(t *testing.T) { bodyStr := 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"`} + expectedFields := []string{`"url":"/api/v1/post/test"`, `"title":"A different title"`, `"date":"2018-01-01"`, `"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) @@ -359,7 +359,7 @@ func TestApiV1Stats(t *testing.T) { now := time.Now() date := now.Format("2006-01-02") month := now.Format("2006-01") - expected := fmt.Sprintf(`{"posts":{"count":3,"first_post_date":"Monday January 1, 2018"},"configuration":{"title":"Jamie's Journal","description":"A private journal containing Jamie's innermost thoughts","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true},"visits":{"daily":[{"date":"%sT00:00:00Z","api_hits":1,"web_hits":0,"total":1}],"monthly":[{"month":"%s","api_hits":1,"web_hits":0,"total":1}]}}`, date, month) + expected := fmt.Sprintf(`{"posts":{"count":3,"first_post_date":"2018-01-01"},"configuration":{"title":"Jamie's Journal","description":"A private journal containing Jamie's innermost thoughts","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true},"visits":{"daily":[{"date":"%s","api_hits":1,"web_hits":0,"total":1}],"monthly":[{"month":"%s","api_hits":1,"web_hits":0,"total":1}]}}`, date, month) // Use contains to get rid of any extra whitespace that we can discount if !strings.Contains(string(body[:]), expected) { diff --git a/pkg/session/store.go b/pkg/session/store.go index f9d9460..61e7245 100644 --- a/pkg/session/store.go +++ b/pkg/session/store.go @@ -76,6 +76,8 @@ func (s *DefaultStore) Get(r *http.Request) (*Session, error) { } if err == nil { s.cachedSession = session + } else { + s.cachedSession = NewSession() } } diff --git a/test/mocks/database/database.go b/test/mocks/database/database.go index b8621cc..034c170 100644 --- a/test/mocks/database/database.go +++ b/test/mocks/database/database.go @@ -90,8 +90,10 @@ 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 + createdAt := time.Date(2018, 2, 1, 10, 0, 0, 0, time.UTC) + updatedAt := time.Date(2018, 2, 1, 10, 0, 0, 0, time.UTC) + *dest[5].(**time.Time) = &createdAt + *dest[6].(**time.Time) = &updatedAt } return nil } diff --git a/web/static/openapi.yml b/web/static/openapi.yml index fb244dd..59f52e0 100644 --- a/web/static/openapi.yml +++ b/web/static/openapi.yml @@ -113,8 +113,8 @@ components: example: 'My Journal Post' date: type: string - format: date-time - example: '2018-06-21T09:12:00Z' + format: date + example: '2018-06-21' content: type: string example: 'Some post content.' @@ -172,7 +172,7 @@ components: date: type: string format: date - example: '2018-06-2' + example: '2018-06-21' content: type: string example: 'Some post content.' @@ -206,7 +206,8 @@ components: example: 42 first_post_date: type: string - example: 'Monday January 1, 2018' + format: date + example: '2018-01-01' configuration: type: object required: @@ -226,7 +227,7 @@ components: example: "A private journal containing Jamie's innermost thoughts" theme: type: string - example: "default" + example: 'default' posts_per_page: type: integer example: 20 @@ -251,7 +252,7 @@ components: date: type: string format: date - example: "2023-12-25" + example: '2023-12-25' api_hits: type: integer example: 15 @@ -269,7 +270,7 @@ components: properties: month: type: string - example: "2023-12" + example: '2023-12' api_hits: type: integer example: 450 From 30e1097b57e188708f8450b17e0860c4701f755e Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 8 Feb 2026 20:41:28 +0000 Subject: [PATCH 44/52] switch out default config names and articles/posts --- Dockerfile | 2 +- Dockerfile.test | 2 +- README.md | 8 +-- internal/app/app.go | 53 ++++++++++--------- internal/app/app_test.go | 36 +++++++++++-- internal/app/controller/apiv1/list.go | 2 +- internal/app/controller/apiv1/stats.go | 4 +- internal/app/controller/apiv1/stats_test.go | 4 +- .../app/controller/web/badrequest_test.go | 2 +- internal/app/controller/web/edit_test.go | 2 +- internal/app/controller/web/index_test.go | 6 +-- internal/app/controller/web/new_test.go | 2 +- internal/app/controller/web/stats.go | 26 ++++----- internal/app/controller/web/stats_test.go | 4 +- internal/app/controller/web/view_test.go | 2 +- journal.go | 4 +- journal_test.go | 2 +- web/templates/stats.html.tmpl | 2 +- 18 files changed, 95 insertions(+), 68 deletions(-) diff --git a/Dockerfile b/Dockerfile index e665387..e856fa5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,13 +18,13 @@ COPY --from=0 /go/src/github.com/jamiefdhurst/journal/web web RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends --assume-yes libsqlite3-0 ENV GOPATH "/go" -ENV J_ARTICLES_PER_PAGE "" ENV J_CREATE "" ENV J_DB_PATH "" ENV J_DESCRIPTION "" ENV J_EDIT "" ENV J_GA_CODE "" ENV J_PORT "" +ENV J_POSTS_PER_PAGE "" ENV J_THEME "" ENV J_TITLE "" diff --git a/Dockerfile.test b/Dockerfile.test index 69a5729..5f57dbb 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,13 +1,13 @@ FROM golang:1.22-bookworm LABEL org.opencontainers.image.source=https://github.com/jamiefdhurst/journal -ENV J_ARTICLES_PER_PAGE "" ENV J_CREATE "" ENV J_DB_PATH "" ENV J_DESCRIPTION "" ENV J_EDIT "" ENV J_GA_CODE "" ENV J_PORT "" +ENV J_POSTS_PER_PAGE "" ENV J_THEME "" ENV J_TITLE "" diff --git a/README.md b/README.md index e296dd1..81fc5d1 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,14 @@ any additional environment variables. ### General Configuration -* `J_ARTICLES_PER_PAGE` - Articles to display per page, default `20` -* `J_CREATE` - Set to `0` to disable article creation +* `J_CREATE` - Set to `0` to disable post creation * `J_DB_PATH` - Path to SQLite DB - default is `$GOPATH/data/journal.db` * `J_DESCRIPTION` - Set the HTML description of the Journal -* `J_EDIT` - Set to `0` to disable article modification -* `J_EXCERPT_WORDS` - The length of the article shown as a preview/excerpt in the index, default `50` +* `J_EDIT` - Set to `0` to disable post modification +* `J_EXCERPT_WORDS` - The length of the post shown as a preview/excerpt in the index, default `50` * `J_GA_CODE` - Google Analytics tag value, starts with `UA-`, or ignore to disable Google Analytics * `J_PORT` - Port to expose over HTTP, default is `3000` +* `J_POSTS_PER_PAGE` - Posts to display per page, default `20` * `J_THEME` - Theme to use from within the _web/themes_ folder, defaults to `default` * `J_TITLE` - Set the title of the Journal diff --git a/internal/app/app.go b/internal/app/app.go index acff1d5..a30faef 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -36,7 +36,6 @@ type Container struct { // Configuration can be modified through environment variables type Configuration struct { - ArticlesPerPage int DatabasePath string Description string EnableCreate bool @@ -44,6 +43,7 @@ type Configuration struct { ExcerptWords int GoogleAnalyticsCode string Port string + PostsPerPage int SSLCertificate string SSLKey string StaticPath string @@ -61,20 +61,20 @@ type Configuration struct { // DefaultConfiguration returns the default settings for the app func DefaultConfiguration() Configuration { return Configuration{ - ArticlesPerPage: 20, DatabasePath: os.Getenv("GOPATH") + "/data/journal.db", - Description: "A private journal containing Jamie's innermost thoughts", + Description: "A fantastic journal containing some thoughts, ideas and reflections", EnableCreate: true, EnableEdit: true, ExcerptWords: 50, GoogleAnalyticsCode: "", Port: "3000", + PostsPerPage: 20, SSLCertificate: "", SSLKey: "", StaticPath: "web/static", Theme: "default", ThemePath: "web/themes", - Title: "Jamie's Journal", + Title: "A Fantastic Journal", SessionKey: "", SessionName: "journal-session", CookieDomain: "", @@ -99,9 +99,14 @@ func ApplyEnvConfiguration(config *Configuration) { return dotenvVars[key] } + // J_ARTICLES_PER_PAGE is deprecated, but it's checked first articles, _ := strconv.Atoi(getEnv("J_ARTICLES_PER_PAGE")) if articles > 0 { - config.ArticlesPerPage = articles + config.PostsPerPage = articles + } + posts, _ := strconv.Atoi(getEnv("J_POSTS_PER_PAGE")) + if posts > 0 { + config.PostsPerPage = posts } database := getEnv("J_DB_PATH") if database != "" { @@ -128,8 +133,25 @@ func ApplyEnvConfiguration(config *Configuration) { if port != "" { config.Port = port } + config.SSLCertificate = getEnv("J_SSL_CERT") config.SSLKey = getEnv("J_SSL_KEY") + staticPath := getEnv("J_STATIC_PATH") + if staticPath != "" { + config.StaticPath = staticPath + } + theme := getEnv("J_THEME") + if theme != "" { + config.Theme = theme + } + themePath := getEnv("J_THEME_PATH") + if themePath != "" { + config.ThemePath = themePath + } + title := getEnv("J_TITLE") + if title != "" { + config.Title = title + } sessionKey := getEnv("J_SESSION_KEY") if sessionKey != "" { @@ -151,40 +173,19 @@ func ApplyEnvConfiguration(config *Configuration) { if sessionName != "" { config.SessionName = sessionName } - cookieDomain := getEnv("J_COOKIE_DOMAIN") if cookieDomain != "" { config.CookieDomain = cookieDomain } - cookieMaxAge, _ := strconv.Atoi(getEnv("J_COOKIE_MAX_AGE")) if cookieMaxAge > 0 { config.CookieMaxAge = cookieMaxAge } - cookieHTTPOnly := getEnv("J_COOKIE_HTTPONLY") if cookieHTTPOnly == "0" || cookieHTTPOnly == "false" { config.CookieHTTPOnly = false } - if config.SSLCertificate != "" { config.CookieSecure = true } - - staticPath := getEnv("J_STATIC_PATH") - if staticPath != "" { - config.StaticPath = staticPath - } - theme := getEnv("J_THEME") - if theme != "" { - config.Theme = theme - } - themePath := getEnv("J_THEME_PATH") - if themePath != "" { - config.ThemePath = themePath - } - title := getEnv("J_TITLE") - if title != "" { - config.Title = title - } } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index b0835ac..a9978f2 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -9,12 +9,12 @@ import ( func TestDefaultConfiguration(t *testing.T) { config := DefaultConfiguration() - if config.ArticlesPerPage != 20 { - t.Errorf("Expected ArticlesPerPage 20, got %d", config.ArticlesPerPage) - } if config.Port != "3000" { t.Errorf("Expected Port '3000', got %q", config.Port) } + if config.PostsPerPage != 20 { + t.Errorf("Expected PostsPerPage 20, got %d", config.PostsPerPage) + } if config.SessionName != "journal-session" { t.Errorf("Expected SessionName 'journal-session', got %q", config.SessionName) } @@ -389,8 +389,8 @@ J_COOKIE_MAX_AGE=3600 if config.Description != "A test journal" { t.Errorf("Expected Description 'A test journal' from .env, got %q", config.Description) } - if config.ArticlesPerPage != 15 { - t.Errorf("Expected ArticlesPerPage 15 from .env, got %d", config.ArticlesPerPage) + if config.PostsPerPage != 15 { + t.Errorf("Expected PostsPerPage 15 from .env, got %d", config.PostsPerPage) } if config.CookieMaxAge != 3600 { t.Errorf("Expected CookieMaxAge 3600 from .env, got %d", config.CookieMaxAge) @@ -455,3 +455,29 @@ func TestApplyEnvConfiguration_NoDotEnvFile(t *testing.T) { t.Errorf("Expected default Port '3000', got %q", config.Port) } } + +func TestApplyEnvConfiguration_ArticlesDeprecated(t *testing.T) { + // Save current working directory + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a temporary directory for testing + tmpDir := t.TempDir() + os.Chdir(tmpDir) + + // Create a .env file + envContent := ` +J_POSTS_PER_PAGE=15 +J_ARTICLES_PER_PAGE=10 +` + if err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644); err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.PostsPerPage != 15 { + t.Errorf("Expected PostsPerPage 15 from .env, got %d", config.PostsPerPage) + } +} diff --git a/internal/app/controller/apiv1/list.go b/internal/app/controller/apiv1/list.go index 7fc450e..30a0f21 100644 --- a/internal/app/controller/apiv1/list.go +++ b/internal/app/controller/apiv1/list.go @@ -23,7 +23,7 @@ type List struct { } func ListData(request *http.Request, js model.Journals) ([]model.Journal, database.PaginationInformation) { - paginationQuery := database.PaginationQuery{Page: 1, ResultsPerPage: js.Container.Configuration.ArticlesPerPage} + paginationQuery := database.PaginationQuery{Page: 1, ResultsPerPage: js.Container.Configuration.PostsPerPage} query := request.URL.Query() if query["page"] != nil { page, err := strconv.Atoi(query["page"][0]) diff --git a/internal/app/controller/apiv1/stats.go b/internal/app/controller/apiv1/stats.go index 07ca5b3..b67fb21 100644 --- a/internal/app/controller/apiv1/stats.go +++ b/internal/app/controller/apiv1/stats.go @@ -34,7 +34,7 @@ type statsConfigJSON struct { Title string `json:"title"` Description string `json:"description"` Theme string `json:"theme"` - ArticlesPerPage int `json:"posts_per_page"` + PostsPerPage int `json:"posts_per_page"` GoogleAnalytics bool `json:"google_analytics"` CreateEnabled bool `json:"create_enabled"` EditEnabled bool `json:"edit_enabled"` @@ -58,7 +58,7 @@ func (c *Stats) Run(response http.ResponseWriter, request *http.Request) { stats.Configuration.Title = container.Configuration.Title stats.Configuration.Description = container.Configuration.Description stats.Configuration.Theme = container.Configuration.Theme - stats.Configuration.ArticlesPerPage = container.Configuration.ArticlesPerPage + stats.Configuration.PostsPerPage = container.Configuration.PostsPerPage stats.Configuration.GoogleAnalytics = container.Configuration.GoogleAnalyticsCode != "" stats.Configuration.CreateEnabled = container.Configuration.EnableCreate stats.Configuration.EditEnabled = container.Configuration.EnableEdit diff --git a/internal/app/controller/apiv1/stats_test.go b/internal/app/controller/apiv1/stats_test.go index 435a6a0..7f5c583 100644 --- a/internal/app/controller/apiv1/stats_test.go +++ b/internal/app/controller/apiv1/stats_test.go @@ -13,7 +13,7 @@ import ( func TestStats_Run(t *testing.T) { db := &database.MockSqlite{} configuration := app.DefaultConfiguration() - configuration.ArticlesPerPage = 25 // Custom setting + configuration.PostsPerPage = 25 // Custom setting configuration.GoogleAnalyticsCode = "UA-123456" // Custom GA code container := &app.Container{Configuration: configuration, Db: db} response := &controller.MockResponse{} @@ -38,7 +38,7 @@ func TestStats_Run(t *testing.T) { t.Errorf("Expected post count to be 2, got response %s", response.Content) } if !strings.Contains(response.Content, "posts_per_page\":25,") { - t.Errorf("Expected articles per page to be 25, got response %s", response.Content) + t.Errorf("Expected posts per page to be 25, got response %s", response.Content) } if !strings.Contains(response.Content, "google_analytics\":true") { t.Error("Expected Google Analytics to be enabled") diff --git a/internal/app/controller/web/badrequest_test.go b/internal/app/controller/web/badrequest_test.go index 7a979de..f71b3ea 100644 --- a/internal/app/controller/web/badrequest_test.go +++ b/internal/app/controller/web/badrequest_test.go @@ -35,7 +35,7 @@ func TestError_Run(t *testing.T) { if response.StatusCode != 404 || !strings.Contains(response.Content, "Page Not Found") { t.Error("Expected 404 error when journal not found") } - if !strings.Contains(response.Content, "Page Not Found - Jamie's Journal") { + if !strings.Contains(response.Content, "Page Not Found - A Fantastic Journal") { t.Error("Expected HTML title to be in place") } diff --git a/internal/app/controller/web/edit_test.go b/internal/app/controller/web/edit_test.go index 43f2ee5..2b85325 100644 --- a/internal/app/controller/web/edit_test.go +++ b/internal/app/controller/web/edit_test.go @@ -73,7 +73,7 @@ func TestEdit_Run(t *testing.T) { if strings.Contains(response.Content, "div class=\"error\"") { t.Error("Expected no error to be shown in form") } - if !strings.Contains(response.Content, "Edit Title - Jamie's Journal") { + if !strings.Contains(response.Content, "Edit Title - A Fantastic Journal") { t.Error("Expected HTML title to be in place") } diff --git a/internal/app/controller/web/index_test.go b/internal/app/controller/web/index_test.go index e9e2888..18a7a97 100644 --- a/internal/app/controller/web/index_test.go +++ b/internal/app/controller/web/index_test.go @@ -25,7 +25,7 @@ func init() { func TestIndex_Run(t *testing.T) { db := &database.MockSqlite{} configuration := app.DefaultConfiguration() - configuration.ArticlesPerPage = 2 + configuration.PostsPerPage = 2 configuration.SessionKey = "12345678901234567890123456789012" container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() @@ -42,10 +42,10 @@ func TestIndex_Run(t *testing.T) { if !strings.Contains(response.Content, "Title 2") { t.Error("Expected all journals to be displayed on screen") } - if !strings.Contains(response.Content, "Jamie's Journal") { + if !strings.Contains(response.Content, "A Fantastic Journal") { t.Error("Expected default HTML title to be in place") } - if !strings.Contains(response.Content, "Create New Post - Jamie's Journal") { + if !strings.Contains(response.Content, "Create New Post - A Fantastic Journal") { t.Error("Expected HTML title to be in place") } diff --git a/internal/app/controller/web/stats.go b/internal/app/controller/web/stats.go index 62b356f..2f8d2fe 100644 --- a/internal/app/controller/web/stats.go +++ b/internal/app/controller/web/stats.go @@ -15,18 +15,18 @@ type Stats struct { } type statsTemplateData struct { - Container *app.Container - PostCount int - FirstPostDate string - TitleSet bool - DescriptionSet bool - ThemeSet bool - ArticlesPerPage int - GACodeSet bool - CreateEnabled bool - EditEnabled bool - DailyVisits []model.DailyVisit - MonthlyVisits []model.MonthlyVisit + Container *app.Container + PostCount int + FirstPostDate string + TitleSet bool + DescriptionSet bool + ThemeSet bool + PostsPerPage int + GACodeSet bool + CreateEnabled bool + EditEnabled bool + DailyVisits []model.DailyVisit + MonthlyVisits []model.MonthlyVisit } // Run Stats action @@ -52,7 +52,7 @@ func (c *Stats) Run(response http.ResponseWriter, request *http.Request) { data.TitleSet = container.Configuration.Title != defaultConfig.Title data.DescriptionSet = container.Configuration.Description != defaultConfig.Description data.ThemeSet = container.Configuration.Theme != defaultConfig.Theme - data.ArticlesPerPage = container.Configuration.ArticlesPerPage + data.PostsPerPage = container.Configuration.PostsPerPage data.GACodeSet = container.Configuration.GoogleAnalyticsCode != "" data.CreateEnabled = container.Configuration.EnableCreate data.EditEnabled = container.Configuration.EnableEdit diff --git a/internal/app/controller/web/stats_test.go b/internal/app/controller/web/stats_test.go index f437b8d..e413f80 100644 --- a/internal/app/controller/web/stats_test.go +++ b/internal/app/controller/web/stats_test.go @@ -13,7 +13,7 @@ import ( func TestStats_Run(t *testing.T) { db := &database.MockSqlite{} configuration := app.DefaultConfiguration() - configuration.ArticlesPerPage = 25 + configuration.PostsPerPage = 25 configuration.GoogleAnalyticsCode = "UA-123456" container := &app.Container{Configuration: configuration, Db: db} response := controller.NewMockResponse() @@ -39,7 +39,7 @@ func TestStats_Run(t *testing.T) { } if !strings.Contains(response.Content, "
Posts Per Page
\n
25
") { - t.Error("Expected custom articles per page setting to be displayed") + t.Error("Expected custom posts per page setting to be displayed") } if !strings.Contains(response.Content, "
Google Analytics
\n
Enabled
") { diff --git a/internal/app/controller/web/view_test.go b/internal/app/controller/web/view_test.go index 9596db8..721dd7f 100644 --- a/internal/app/controller/web/view_test.go +++ b/internal/app/controller/web/view_test.go @@ -47,7 +47,7 @@ func TestView_Run(t *testing.T) { if strings.Contains(response.Content, "div class=\"error\"") || !strings.Contains(response.Content, "Content") { t.Error("Expected no error to be shown in page") } - if !strings.Contains(response.Content, "Title - Jamie's Journal") { + if !strings.Contains(response.Content, "Title - A Fantastic Journal") { t.Error("Expected HTML title to be in place") } diff --git a/journal.go b/journal.go index 14872b7..4aa69dd 100644 --- a/journal.go +++ b/journal.go @@ -22,10 +22,10 @@ func config() app.Configuration { app.ApplyEnvConfiguration(&configuration) if !configuration.EnableCreate { - log.Println("Article creating is disabled...") + log.Println("Post creating is disabled...") } if !configuration.EnableEdit { - log.Println("Article editing is disabled...") + log.Println("Post editing is disabled...") } return configuration diff --git a/journal_test.go b/journal_test.go index 1224258..c77e06c 100644 --- a/journal_test.go +++ b/journal_test.go @@ -359,7 +359,7 @@ func TestApiV1Stats(t *testing.T) { now := time.Now() date := now.Format("2006-01-02") month := now.Format("2006-01") - expected := fmt.Sprintf(`{"posts":{"count":3,"first_post_date":"2018-01-01"},"configuration":{"title":"Jamie's Journal","description":"A private journal containing Jamie's innermost thoughts","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true},"visits":{"daily":[{"date":"%s","api_hits":1,"web_hits":0,"total":1}],"monthly":[{"month":"%s","api_hits":1,"web_hits":0,"total":1}]}}`, date, month) + expected := fmt.Sprintf(`{"posts":{"count":3,"first_post_date":"2018-01-01"},"configuration":{"title":"A Fantastic Journal","description":"A fantastic journal containing some thoughts, ideas and reflections","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true},"visits":{"daily":[{"date":"%s","api_hits":1,"web_hits":0,"total":1}],"monthly":[{"month":"%s","api_hits":1,"web_hits":0,"total":1}]}}`, date, month) // Use contains to get rid of any extra whitespace that we can discount if !strings.Contains(string(body[:]), expected) { diff --git a/web/templates/stats.html.tmpl b/web/templates/stats.html.tmpl index 679df08..e563447 100644 --- a/web/templates/stats.html.tmpl +++ b/web/templates/stats.html.tmpl @@ -25,7 +25,7 @@
{{.Container.Configuration.Theme}}
Posts Per Page
-
{{.ArticlesPerPage}}
+
{{.PostsPerPage}}
Google Analytics
{{if .GACodeSet}}Enabled{{else}}Disabled{{end}}
From 0384d03ec24c7f2e91491f7ec340b07999a6ed31 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Tue, 24 Feb 2026 16:44:31 +0000 Subject: [PATCH 45/52] fix: refactor folders to be the same as other go projects --- .github/workflows/build.yml | 8 ++++---- .gitignore | 2 +- Dockerfile | 3 ++- Dockerfile.test | 2 +- Makefile | 2 +- {web/static => api}/openapi.yml | 0 journal.go => cmd/journal/main.go | 0 journal_test.go => cmd/journal/main_test.go | 1 + internal/app/router/router.go | 1 + 9 files changed, 11 insertions(+), 8 deletions(-) rename {web/static => api}/openapi.yml (100%) rename journal.go => cmd/journal/main.go (100%) rename journal_test.go => cmd/journal/main_test.go (99%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index afd8bdf..9a1dd99 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,11 +6,11 @@ on: - 'main' paths: - 'api/**' + - 'cmd/**' - 'internal/**' - 'pkg/**' - 'test/**' - 'web/**' - - '*.go' - 'go.*' - '*.md' @@ -51,7 +51,7 @@ jobs: if: ${{ contains(steps.latest.outputs.name, '.') }} uses: datamonsters/replace-action@v2 with: - files: 'journal.go' + files: 'cmd/journal/main.go' replacements: '${{ steps.latest_clean.outputs.name }}=${{ steps.version.outputs.value }}' - name: Update Version in Files (2) if: ${{ contains(steps.latest.outputs.name, '.') }} @@ -63,7 +63,7 @@ jobs: if: ${{ contains(steps.latest.outputs.name, '.') }} uses: datamonsters/replace-action@v2 with: - files: 'web/static/openapi.yml' + files: 'api/openapi.yml' replacements: '${{ steps.latest_clean.outputs.name }}=${{ steps.version.outputs.value }}' - name: File Save Delay uses: jakejarvis/wait-action@master @@ -94,7 +94,7 @@ jobs: run: | sudo apt-get install -y build-essential libsqlite3-dev go mod download - GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal-bin_linux_x64-v${{ steps.version.outputs.value }} . + GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal-bin_linux_x64-v${{ steps.version.outputs.value }} ./cmd/journal cp journal-bin_linux_x64-v${{ steps.version.outputs.value }} bootstrap zip -r journal-lambda_al2023-v${{ steps.version.outputs.value }}.zip bootstrap web -x web/app/\* - name: Create Release diff --git a/.gitignore b/.gitignore index 9ba87d3..0b063b0 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ _testmain.go *.prof coverage.xml data -journal +/journal node_modules test/data/test.db tests.xml diff --git a/Dockerfile b/Dockerfile index e856fa5..8e798f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ COPY . . RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends --assume-yes build-essential libsqlite3-dev; \ go mod download; \ - CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal .; \ + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal ./cmd/journal; \ mv journal /go/bin/journal FROM debian:bookworm @@ -13,6 +13,7 @@ LABEL org.opencontainers.image.source=https://github.com/jamiefdhurst/journal WORKDIR /go/src/github.com/jamiefdhurst/journal COPY --from=0 /go/bin/journal /usr/local/bin/ +COPY --from=0 /go/src/github.com/jamiefdhurst/journal/api api COPY --from=0 /go/src/github.com/jamiefdhurst/journal/web web RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends --assume-yes libsqlite3-0 diff --git a/Dockerfile.test b/Dockerfile.test index 5f57dbb..21a9aa4 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -16,7 +16,7 @@ COPY . . RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends --assume-yes build-essential libsqlite3-dev; \ go mod download; \ - CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal .; \ + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal ./cmd/journal; \ go install github.com/tebeka/go2xunit@latest;\ go install github.com/axw/gocov/gocov@latest; \ go install github.com/AlekSi/gocov-xml@latest; \ diff --git a/Makefile b/Makefile index 24bcc3e..9e3d1d4 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build test build: - @CC=x86_64-unknown-linux-gnu-gcc GOARCH=amd64 GOOS=linux go build -v -o bootstrap . + @CC=x86_64-unknown-linux-gnu-gcc GOARCH=amd64 GOOS=linux go build -v -o bootstrap ./cmd/journal @zip -r lambda.zip bootstrap web -x web/app/\* test: diff --git a/web/static/openapi.yml b/api/openapi.yml similarity index 100% rename from web/static/openapi.yml rename to api/openapi.yml diff --git a/journal.go b/cmd/journal/main.go similarity index 100% rename from journal.go rename to cmd/journal/main.go diff --git a/journal_test.go b/cmd/journal/main_test.go similarity index 99% rename from journal_test.go rename to cmd/journal/main_test.go index c77e06c..08a95b0 100644 --- a/journal_test.go +++ b/cmd/journal/main_test.go @@ -24,6 +24,7 @@ var ( ) func init() { + os.Chdir("../..") //nolint:errcheck container = &app.Container{Configuration: app.DefaultConfiguration()} rtr = router.NewRouter(container) server = httptest.NewServer(rtr) diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 8e33bc5..98b5298 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -15,6 +15,7 @@ func NewRouter(app *app.Container) *pkgrouter.Router { rtr.StaticPaths = []string{ app.Configuration.ThemePath + "/" + app.Configuration.Theme, app.Configuration.StaticPath, + "api", } // API v1 From 92ba0848ea88266e1ca06b2f725b2d2f98cbd4d7 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Wed, 25 Feb 2026 08:18:27 +0000 Subject: [PATCH 46/52] fix: spaces, not tabs --- .editorconfig | 3 + cmd/journal/main.go | 216 ++--- cmd/journal/main_test.go | 684 +++++++------- go.mod | 10 +- internal/app/app.go | 310 +++---- internal/app/app_test.go | 852 +++++++++--------- internal/app/controller/apiv1/create.go | 58 +- internal/app/controller/apiv1/create_test.go | 112 +-- internal/app/controller/apiv1/data.go | 60 +- internal/app/controller/apiv1/list.go | 60 +- internal/app/controller/apiv1/list_test.go | 44 +- internal/app/controller/apiv1/random.go | 40 +- internal/app/controller/apiv1/random_test.go | 90 +- internal/app/controller/apiv1/single.go | 32 +- internal/app/controller/apiv1/single_test.go | 58 +- internal/app/controller/apiv1/stats.go | 88 +- internal/app/controller/apiv1/stats_test.go | 100 +- internal/app/controller/apiv1/update.go | 86 +- internal/app/controller/apiv1/update_test.go | 110 +-- internal/app/controller/web/badrequest.go | 34 +- .../app/controller/web/badrequest_test.go | 58 +- internal/app/controller/web/calendar.go | 238 ++--- internal/app/controller/web/calendar_test.go | 278 +++--- internal/app/controller/web/edit.go | 92 +- internal/app/controller/web/edit_test.go | 266 +++--- internal/app/controller/web/helpers.go | 52 +- internal/app/controller/web/index.go | 92 +- internal/app/controller/web/index_test.go | 146 +-- internal/app/controller/web/new.go | 78 +- internal/app/controller/web/new_test.go | 226 ++--- internal/app/controller/web/random.go | 30 +- internal/app/controller/web/random_test.go | 80 +- internal/app/controller/web/sitemap.go | 32 +- internal/app/controller/web/sitemap_test.go | 60 +- internal/app/controller/web/stats.go | 92 +- internal/app/controller/web/stats_test.go | 104 +-- internal/app/controller/web/view.go | 50 +- internal/app/controller/web/view_test.go | 126 +-- internal/app/model/journal.go | 470 +++++----- internal/app/model/journal_test.go | 786 ++++++++-------- internal/app/model/migration.go | 270 +++--- internal/app/model/migration_test.go | 170 ++-- internal/app/model/visit.go | 226 ++--- internal/app/model/visit_test.go | 238 ++--- internal/app/router/router.go | 66 +- pkg/controller/controller.go | 126 +-- pkg/controller/controller_test.go | 98 +- pkg/database/database.go | 46 +- pkg/database/database_test.go | 68 +- pkg/database/pagination.go | 96 +- pkg/database/pagination_test.go | 80 +- pkg/database/rows/rows.go | 8 +- pkg/env/parser.go | 100 +- pkg/env/parser_test.go | 276 +++--- pkg/markdown/markdown.go | 114 +-- pkg/markdown/markdown_test.go | 196 ++-- pkg/router/router.go | 138 +-- pkg/router/router_test.go | 324 +++---- pkg/session/session.go | 44 +- pkg/session/store.go | 248 ++--- pkg/session/store_test.go | 630 ++++++------- test/mocks/adapter/adapter.go | 24 +- test/mocks/controller/controller.go | 50 +- test/mocks/database/database.go | 340 +++---- test/mocks/router/router.go | 10 +- 65 files changed, 5196 insertions(+), 5193 deletions(-) diff --git a/.editorconfig b/.editorconfig index 5b1b0c0..9c34be4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,6 +2,9 @@ indent_size = 4 indent_style = space +[Makefile] +indent_style = tab + [*.yml] indent_size = 2 indent_style = space diff --git a/cmd/journal/main.go b/cmd/journal/main.go index 4aa69dd..b9b664e 100644 --- a/cmd/journal/main.go +++ b/cmd/journal/main.go @@ -1,123 +1,123 @@ package main import ( - "crypto/tls" - "fmt" - "log" - "net/http" - "os" - - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/internal/app/router" - "github.com/jamiefdhurst/journal/pkg/database" - "github.com/jamiefdhurst/journal/pkg/markdown" + "crypto/tls" + "fmt" + "log" + "net/http" + "os" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/internal/app/router" + "github.com/jamiefdhurst/journal/pkg/database" + "github.com/jamiefdhurst/journal/pkg/markdown" ) var container *app.Container = &app.Container{} func config() app.Configuration { - // Define default configuration - configuration := app.DefaultConfiguration() - app.ApplyEnvConfiguration(&configuration) - - if !configuration.EnableCreate { - log.Println("Post creating is disabled...") - } - if !configuration.EnableEdit { - log.Println("Post editing is disabled...") - } - - return configuration + // Define default configuration + configuration := app.DefaultConfiguration() + app.ApplyEnvConfiguration(&configuration) + + if !configuration.EnableCreate { + log.Println("Post creating is disabled...") + } + if !configuration.EnableEdit { + log.Println("Post editing is disabled...") + } + + return configuration } func loadDatabase() func() { - container.Db = &database.Sqlite{} - - // Set up the markdown processor - container.MarkdownProcessor = &markdown.Markdown{} - - log.Printf("Loading DB from %s...\n", container.Configuration.DatabasePath) - if err := container.Db.Connect(container.Configuration.DatabasePath); err != nil { - log.Printf("Database error - please verify that the %s path is available and writeable.\nError: %s\n", container.Configuration.DatabasePath, err) - os.Exit(1) - } - - // Create needed tables - js := model.Journals{Container: container} - if err := js.CreateTable(); err != nil { - log.Printf("Error creating journal table: %s\n", err) - log.Panicln(err) - } - ms := model.Migrations{Container: container} - if err := ms.CreateTable(); err != nil { - log.Printf("Error creating migrations table: %s\n", err) - log.Panicln(err) - } - vs := model.Visits{Container: container} - if err := vs.CreateTable(); err != nil { - log.Printf("Error creating visits table: %s\n", err) - log.Panicln(err) - } - - // Run migrations - if err := ms.MigrateHTMLToMarkdown(); err != nil { - log.Printf("Error during HTML to Markdown migration: %s\n", err) - log.Panicln(err) - } - if err := ms.MigrateRandomSlugs(); err != nil { - 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() - } + container.Db = &database.Sqlite{} + + // Set up the markdown processor + container.MarkdownProcessor = &markdown.Markdown{} + + log.Printf("Loading DB from %s...\n", container.Configuration.DatabasePath) + if err := container.Db.Connect(container.Configuration.DatabasePath); err != nil { + log.Printf("Database error - please verify that the %s path is available and writeable.\nError: %s\n", container.Configuration.DatabasePath, err) + os.Exit(1) + } + + // Create needed tables + js := model.Journals{Container: container} + if err := js.CreateTable(); err != nil { + log.Printf("Error creating journal table: %s\n", err) + log.Panicln(err) + } + ms := model.Migrations{Container: container} + if err := ms.CreateTable(); err != nil { + log.Printf("Error creating migrations table: %s\n", err) + log.Panicln(err) + } + vs := model.Visits{Container: container} + if err := vs.CreateTable(); err != nil { + log.Printf("Error creating visits table: %s\n", err) + log.Panicln(err) + } + + // Run migrations + if err := ms.MigrateHTMLToMarkdown(); err != nil { + log.Printf("Error during HTML to Markdown migration: %s\n", err) + log.Panicln(err) + } + if err := ms.MigrateRandomSlugs(); err != nil { + 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() + } } func main() { - const version = "0.9.6" - fmt.Printf("Journal v%s\n-------------------\n\n", version) - - configuration := config() - - // Create/define container - container.Configuration = configuration - container.Version = version - - closeFunc := loadDatabase() - defer closeFunc() - - router := router.NewRouter(container) - - var err error - var protocols http.Protocols - protocols.SetHTTP1(true) - protocols.SetHTTP2(true) - protocols.SetUnencryptedHTTP2(true) - server := &http.Server{ - Addr: ":" + configuration.Port, - Handler: router, - Protocols: &protocols, - TLSConfig: &tls.Config{ - MinVersion: tls.VersionTLS13, - }, - } - log.Printf("Ready and listening on port %s...\n", configuration.Port) - if configuration.SSLCertificate == "" { - err = router.StartAndServe(server) - } else { - log.Printf("Certificate: %s\n", configuration.SSLCertificate) - log.Printf("Certificate Key: %s\n", configuration.SSLKey) - log.Println("Serving with SSL enabled...") - err = router.StartAndServeTLS(server, configuration.SSLCertificate, configuration.SSLKey) - } - - if err != nil { - log.Fatal("Error reported: ", err) - } + const version = "0.9.6" + fmt.Printf("Journal v%s\n-------------------\n\n", version) + + configuration := config() + + // Create/define container + container.Configuration = configuration + container.Version = version + + closeFunc := loadDatabase() + defer closeFunc() + + router := router.NewRouter(container) + + var err error + var protocols http.Protocols + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + protocols.SetUnencryptedHTTP2(true) + server := &http.Server{ + Addr: ":" + configuration.Port, + Handler: router, + Protocols: &protocols, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + }, + } + log.Printf("Ready and listening on port %s...\n", configuration.Port) + if configuration.SSLCertificate == "" { + err = router.StartAndServe(server) + } else { + log.Printf("Certificate: %s\n", configuration.SSLCertificate) + log.Printf("Certificate Key: %s\n", configuration.SSLKey) + log.Println("Serving with SSL enabled...") + err = router.StartAndServeTLS(server, configuration.SSLCertificate, configuration.SSLKey) + } + + if err != nil { + log.Fatal("Error reported: ", err) + } } diff --git a/cmd/journal/main_test.go b/cmd/journal/main_test.go index 08a95b0..ea9e869 100644 --- a/cmd/journal/main_test.go +++ b/cmd/journal/main_test.go @@ -1,478 +1,478 @@ package main import ( - "fmt" - "io" - "log" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - "time" - - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/internal/app/router" - "github.com/jamiefdhurst/journal/pkg/database" - pkgrouter "github.com/jamiefdhurst/journal/pkg/router" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/internal/app/router" + "github.com/jamiefdhurst/journal/pkg/database" + pkgrouter "github.com/jamiefdhurst/journal/pkg/router" ) var ( - rtr *pkgrouter.Router - server *httptest.Server + rtr *pkgrouter.Router + server *httptest.Server ) func init() { - os.Chdir("../..") //nolint:errcheck - container = &app.Container{Configuration: app.DefaultConfiguration()} - rtr = router.NewRouter(container) - server = httptest.NewServer(rtr) + os.Chdir("../..") //nolint:errcheck + container = &app.Container{Configuration: app.DefaultConfiguration()} + rtr = router.NewRouter(container) + server = httptest.NewServer(rtr) - log.Println("Serving on " + server.URL) + log.Println("Serving on " + server.URL) } func fixtures(t *testing.T) { - db := &database.Sqlite{} - if err := db.Connect("test/data/test.db"); err != nil { - t.Error("Could not open test database for writing...") - } - - // Setup container - 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") - db.Exec("INSERT INTO journal (slug, title, content, date) VALUES (?, ?, ?, ?)", "test-2", "Another Test", "

Test again!

", "2018-02-01") - db.Exec("INSERT INTO journal (slug, title, content, date) VALUES (?, ?, ?, ?)", "test-3", "A Final Test", "

Test finally!

", "2018-03-01") + db := &database.Sqlite{} + if err := db.Connect("test/data/test.db"); err != nil { + t.Error("Could not open test database for writing...") + } + + // Setup container + 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") + db.Exec("INSERT INTO journal (slug, title, content, date) VALUES (?, ?, ?, ?)", "test-2", "Another Test", "

Test again!

", "2018-02-01") + db.Exec("INSERT INTO journal (slug, title, content, date) VALUES (?, ?, ?, ?)", "test-3", "A Final Test", "

Test finally!

", "2018-03-01") } func TestConfig(t *testing.T) { - os.Setenv("J_TITLE", "A Test Title") - - configuration := config() - - if configuration.Title != "A Test Title" { - t.Error("Expected title to be set through environment") - } - if configuration.Port != "3000" { - t.Errorf("Expected default port to be set, got %s", configuration.Port) - } - if configuration.Theme != "default" { - t.Errorf("Expected default theme to be set, got %s", configuration.Theme) - } + os.Setenv("J_TITLE", "A Test Title") + + configuration := config() + + if configuration.Title != "A Test Title" { + t.Error("Expected title to be set through environment") + } + if configuration.Port != "3000" { + t.Errorf("Expected default port to be set, got %s", configuration.Port) + } + if configuration.Theme != "default" { + t.Errorf("Expected default theme to be set, got %s", configuration.Theme) + } } func TestLoadDatabase(t *testing.T) { - container.Configuration.DatabasePath = "test/data/test.db" - closeFunc := loadDatabase() - closeFunc() + container.Configuration.DatabasePath = "test/data/test.db" + closeFunc := loadDatabase() + closeFunc() } func TestApiv1List(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("GET", server.URL+"/api/v1/post", nil) + request, _ := http.NewRequest("GET", server.URL+"/api/v1/post", nil) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 200 { - t.Error("Expected 200 status code") - } + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) - expected := `{"links":{},"pagination":{"current_page":1,"total_pages":1,"posts_per_page":20,"total_posts":3},"posts":[{"url":"/api/v1/post/test-3","title":"A Final Test","date":"2018-03-01","content":"

Test finally!

"},{"url":"/api/v1/post/test-2","title":"Another Test","date":"2018-02-01","content":"

Test again!

"},{"url":"/api/v1/post/test","title":"Test","date":"2018-01-01","content":"

Test!

"}]}` + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + expected := `{"links":{},"pagination":{"current_page":1,"total_pages":1,"posts_per_page":20,"total_posts":3},"posts":[{"url":"/api/v1/post/test-3","title":"A Final Test","date":"2018-03-01","content":"

Test finally!

"},{"url":"/api/v1/post/test-2","title":"Another Test","date":"2018-02-01","content":"

Test again!

"},{"url":"/api/v1/post/test","title":"Test","date":"2018-01-01","content":"

Test!

"}]}` - // 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[:])) - } + // 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[:])) + } } func TestApiV1Single(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("GET", server.URL+"/api/v1/post/test", nil) + request, _ := http.NewRequest("GET", server.URL+"/api/v1/post/test", nil) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 200 { - t.Error("Expected 200 status code") - } + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) - expected := `{"url":"/api/v1/post/test","title":"Test","date":"2018-01-01","content":"

Test!

"}` + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + expected := `{"url":"/api/v1/post/test","title":"Test","date":"2018-01-01","content":"

Test!

"}` - // 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[:])) - } + // 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[:])) + } } func TestApiV1Single_NotFound(t *testing.T) { - fixtures(t) + fixtures(t) - // Try a post that doesn't exist, but is not the new random endpoint - request, _ := http.NewRequest("GET", server.URL+"/api/v1/post/nonexistent", nil) + // Try a post that doesn't exist, but is not the new random endpoint + request, _ := http.NewRequest("GET", server.URL+"/api/v1/post/nonexistent", nil) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 404 { - t.Error("Expected 404 status code") - } + if res.StatusCode != 404 { + t.Error("Expected 404 status code") + } } func TestApiV1Random(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("GET", server.URL+"/api/v1/post/random", nil) + request, _ := http.NewRequest("GET", server.URL+"/api/v1/post/random", nil) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 200 { - t.Error("Expected 200 status code") - } + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) - // Make sure we got a valid JSON response - if !strings.Contains(string(body[:]), "\"url\":") || !strings.Contains(string(body[:]), "\"title\":") { - t.Errorf("Expected JSON with id and slug, got: %s", string(body[:])) - } + // Make sure we got a valid JSON response + if !strings.Contains(string(body[:]), "\"url\":") || !strings.Contains(string(body[:]), "\"title\":") { + t.Errorf("Expected JSON with id and slug, got: %s", string(body[:])) + } } func TestApiV1Create(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Test 4","date":"2018-06-01T00:00:00Z","content":"

Test 4!

"}`)) + request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Test 4","date":"2018-06-01T00:00:00Z","content":"

Test 4!

"}`)) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 201 { - t.Error("Expected 201 status code") - } + if res.StatusCode != 201 { + t.Error("Expected 201 status code") + } - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) - bodyStr := string(body[:]) + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + bodyStr := string(body[:]) - // Check for expected fields - expectedFields := []string{`"url":"/api/v1/post/test-4"`, `"title":"Test 4"`, `"date":"2018-06-01"`, `"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) - } - } + // Check for expected fields + expectedFields := []string{`"url":"/api/v1/post/test-4"`, `"title":"Test 4"`, `"date":"2018-06-01"`, `"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) + } + } } func TestApiV1Create_InvalidRequest(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", nil) + request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", nil) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 400 { - t.Error("Expected 400 status code") - } + if res.StatusCode != 400 { + t.Error("Expected 400 status code") + } } func TestApiV1Create_MissingData(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Test 4"}`)) + request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Test 4"}`)) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 400 { - t.Error("Expected 400 status code") - } + if res.StatusCode != 400 { + t.Error("Expected 400 status code") + } } func TestApiV1Create_RepeatTitles(t *testing.T) { - fixtures(t) - - request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Repeated","date":"2018-02-01T00:00:00Z","content":"

Repeated content test!

"}`)) - res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } - if res.StatusCode != 201 { - t.Error("Expected 201 status code") - } - - request, _ = http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Repeated","date":"2019-02-01T00:00:00Z","content":"

Repeated content test again!

"}`)) - res, err = http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } - if res.StatusCode != 201 { - t.Error("Expected 201 status code") - } - - request, _ = http.NewRequest("GET", server.URL+"/api/v1/post/repeated-1", nil) - res, err = http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } - if res.StatusCode != 200 { - t.Error("Expected 200 status code") - } - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) - bodyStr := string(body[:]) - - // Check for expected fields - expectedFields := []string{`"url":"/api/v1/post/repeated-1"`, `"title":"Repeated"`, `"date":"2019-02-01"`, `"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) - } - } + fixtures(t) + + request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Repeated","date":"2018-02-01T00:00:00Z","content":"

Repeated content test!

"}`)) + res, err := http.DefaultClient.Do(request) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + if res.StatusCode != 201 { + t.Error("Expected 201 status code") + } + + request, _ = http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Repeated","date":"2019-02-01T00:00:00Z","content":"

Repeated content test again!

"}`)) + res, err = http.DefaultClient.Do(request) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + if res.StatusCode != 201 { + t.Error("Expected 201 status code") + } + + request, _ = http.NewRequest("GET", server.URL+"/api/v1/post/repeated-1", nil) + res, err = http.DefaultClient.Do(request) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + bodyStr := string(body[:]) + + // Check for expected fields + expectedFields := []string{`"url":"/api/v1/post/repeated-1"`, `"title":"Repeated"`, `"date":"2019-02-01"`, `"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) + } + } } func TestApiV1Update(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("POST", server.URL+"/api/v1/post/test", strings.NewReader(`{"title":"A different title"}`)) + request, _ := http.NewRequest("POST", server.URL+"/api/v1/post/test", strings.NewReader(`{"title":"A different title"}`)) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 200 { - t.Error("Expected 200 status code") - } + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) - bodyStr := string(body[:]) + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + bodyStr := string(body[:]) - // Check for expected fields - expectedFields := []string{`"url":"/api/v1/post/test"`, `"title":"A different title"`, `"date":"2018-01-01"`, `"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) - } - } + // Check for expected fields + expectedFields := []string{`"url":"/api/v1/post/test"`, `"title":"A different title"`, `"date":"2018-01-01"`, `"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) + } + } } func TestApiV1Update_NotFound(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("POST", server.URL+"/api/v1/post/random", strings.NewReader(`{"title":"A different title"}`)) + request, _ := http.NewRequest("POST", server.URL+"/api/v1/post/random", strings.NewReader(`{"title":"A different title"}`)) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 404 { - t.Error("Expected 404 status code") - } + if res.StatusCode != 404 { + t.Error("Expected 404 status code") + } } func TestApiV1Update_InvalidRequest(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("POST", server.URL+"/api/v1/post/test", nil) + request, _ := http.NewRequest("POST", server.URL+"/api/v1/post/test", nil) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 400 { - t.Error("Expected 400 status code") - } + if res.StatusCode != 400 { + t.Error("Expected 400 status code") + } } func TestApiV1Stats(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("GET", server.URL+"/api/v1/stats", nil) + request, _ := http.NewRequest("GET", server.URL+"/api/v1/stats", nil) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 200 { - t.Error("Expected 200 status code") - } + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) - // Check that JSON is returned - if res.Header.Get("Content-Type") != "application/json" { - t.Error("Expected JSON content type") - } + // Check that JSON is returned + if res.Header.Get("Content-Type") != "application/json" { + t.Error("Expected JSON content type") + } - now := time.Now() - date := now.Format("2006-01-02") - month := now.Format("2006-01") - expected := fmt.Sprintf(`{"posts":{"count":3,"first_post_date":"2018-01-01"},"configuration":{"title":"A Fantastic Journal","description":"A fantastic journal containing some thoughts, ideas and reflections","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true},"visits":{"daily":[{"date":"%s","api_hits":1,"web_hits":0,"total":1}],"monthly":[{"month":"%s","api_hits":1,"web_hits":0,"total":1}]}}`, date, month) + now := time.Now() + date := now.Format("2006-01-02") + month := now.Format("2006-01") + expected := fmt.Sprintf(`{"posts":{"count":3,"first_post_date":"2018-01-01"},"configuration":{"title":"A Fantastic Journal","description":"A fantastic journal containing some thoughts, ideas and reflections","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true},"visits":{"daily":[{"date":"%s","api_hits":1,"web_hits":0,"total":1}],"monthly":[{"month":"%s","api_hits":1,"web_hits":0,"total":1}]}}`, date, month) - // 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[:])) - } + // 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[:])) + } } func TestOpenapi(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("GET", server.URL+"/openapi.yml", nil) + request, _ := http.NewRequest("GET", server.URL+"/openapi.yml", nil) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 200 { - t.Error("Expected 200 status code") - } + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) - expected := []string{"openapi: '3.0.3'", "/api/v1/post:", "/api/v1/post/{slug}:", "/api/v1/post/random:", "/api/v1/stats:"} - for _, e := range expected { - if !strings.Contains(string(body[:]), e) { - t.Errorf("Expected:\n\t%s\nGot:\n\t%s", e, string(body[:])) - } - } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + expected := []string{"openapi: '3.0.3'", "/api/v1/post:", "/api/v1/post/{slug}:", "/api/v1/post/random:", "/api/v1/stats:"} + for _, e := range expected { + if !strings.Contains(string(body[:]), e) { + t.Errorf("Expected:\n\t%s\nGot:\n\t%s", e, string(body[:])) + } + } } func TestWebStats(t *testing.T) { - fixtures(t) + fixtures(t) - request, _ := http.NewRequest("GET", server.URL+"/stats", nil) + request, _ := http.NewRequest("GET", server.URL+"/stats", nil) - res, err := http.DefaultClient.Do(request) + res, err := http.DefaultClient.Do(request) - if err != nil { - t.Errorf("Unexpected error: %s", err) - } + if err != nil { + t.Errorf("Unexpected error: %s", err) + } - if res.StatusCode != 200 { - t.Error("Expected 200 status code") - } + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) - // Check for stats page elements - if !strings.Contains(string(body[:]), "

Stats

") { - t.Error("Expected stats page title to be present") - } + // Check for stats page elements + if !strings.Contains(string(body[:]), "

Stats

") { + t.Error("Expected stats page title to be present") + } - // Check for post count (3 from fixtures) - if !strings.Contains(string(body[:]), "Total Posts") || !strings.Contains(string(body[:]), "
3
") { - t.Error("Expected post count to be displayed") - } + // Check for post count (3 from fixtures) + if !strings.Contains(string(body[:]), "Total Posts") || !strings.Contains(string(body[:]), "
3
") { + t.Error("Expected post count to be displayed") + } } func TestVisitTracking(t *testing.T) { - fixtures(t) - - request, _ := http.NewRequest("GET", server.URL+"/", nil) - res, err := http.DefaultClient.Do(request) - - if err != nil { - t.Errorf("Unexpected error: %s", err) - } - - if res.StatusCode != 200 { - t.Error("Expected 200 status code") - } - - res.Body.Close() - - rows, err := container.Db.Query("SELECT COUNT(*) FROM visit WHERE url = '/'") - if err != nil { - t.Errorf("Failed to query visits table: %s", err) - return - } - defer rows.Close() - - var visitCount int - if rows.Next() { - rows.Scan(&visitCount) - } - - if visitCount == 0 { - t.Log("Visit tracking is disabled during test environment - this is expected behaviour") - } else { - t.Logf("Visit tracking is active - found %d visit(s)", visitCount) - - visitRows, err := container.Db.Query("SELECT url, hits FROM visit WHERE url = '/' LIMIT 1") - if err != nil { - t.Errorf("Failed to query visit details: %s", err) - return - } - defer visitRows.Close() - - if visitRows.Next() { - var url string - var hits int - visitRows.Scan(&url, &hits) - - if url != "/" { - t.Errorf("Expected visit URL to be '/', got '%s'", url) - } - if hits != 1 { - t.Errorf("Expected visit hits to be 1, got %d", hits) - } - } - } + fixtures(t) + + request, _ := http.NewRequest("GET", server.URL+"/", nil) + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + + res.Body.Close() + + rows, err := container.Db.Query("SELECT COUNT(*) FROM visit WHERE url = '/'") + if err != nil { + t.Errorf("Failed to query visits table: %s", err) + return + } + defer rows.Close() + + var visitCount int + if rows.Next() { + rows.Scan(&visitCount) + } + + if visitCount == 0 { + t.Log("Visit tracking is disabled during test environment - this is expected behaviour") + } else { + t.Logf("Visit tracking is active - found %d visit(s)", visitCount) + + visitRows, err := container.Db.Query("SELECT url, hits FROM visit WHERE url = '/' LIMIT 1") + if err != nil { + t.Errorf("Failed to query visit details: %s", err) + return + } + defer visitRows.Close() + + if visitRows.Next() { + var url string + var hits int + visitRows.Scan(&url, &hits) + + if url != "/" { + t.Errorf("Expected visit URL to be '/', got '%s'", url) + } + if hits != 1 { + t.Errorf("Expected visit hits to be 1, got %d", hits) + } + } + } } diff --git a/go.mod b/go.mod index b99d9dd..23250ea 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,12 @@ toolchain go1.24.2 require github.com/ncruces/go-sqlite3 v0.25.2 require ( - github.com/ncruces/julianday v1.0.0 // indirect - github.com/tetratelabs/wazero v1.9.0 // indirect - golang.org/x/sys v0.33.0 // indirect + github.com/ncruces/julianday v1.0.0 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + golang.org/x/sys v0.33.0 // indirect ) require ( - github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b - golang.org/x/text v0.25.0 + github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b + golang.org/x/text v0.25.0 ) diff --git a/internal/app/app.go b/internal/app/app.go index a30faef..3b71fa5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,191 +1,191 @@ package app import ( - "crypto/rand" - "database/sql" - "encoding/hex" - "log" - "os" - "strconv" + "crypto/rand" + "database/sql" + "encoding/hex" + "log" + "os" + "strconv" - "github.com/jamiefdhurst/journal/pkg/database/rows" - "github.com/jamiefdhurst/journal/pkg/env" + "github.com/jamiefdhurst/journal/pkg/database/rows" + "github.com/jamiefdhurst/journal/pkg/env" ) // Database Define same interface as database type Database interface { - Close() - Connect(dbFile string) error - Exec(sql string, args ...interface{}) (sql.Result, error) - Query(sql string, args ...interface{}) (rows.Rows, error) + Close() + Connect(dbFile string) error + Exec(sql string, args ...interface{}) (sql.Result, error) + Query(sql string, args ...interface{}) (rows.Rows, error) } // MarkdownProcessor defines an interface for markdown processing type MarkdownProcessor interface { - ToHTML(input string) string - FromHTML(input string) string + ToHTML(input string) string + FromHTML(input string) string } // Container Define the main container for the application type Container struct { - Configuration Configuration - Db Database - Version string - MarkdownProcessor MarkdownProcessor + Configuration Configuration + Db Database + Version string + MarkdownProcessor MarkdownProcessor } // Configuration can be modified through environment variables type Configuration struct { - DatabasePath string - Description string - EnableCreate bool - EnableEdit bool - ExcerptWords int - GoogleAnalyticsCode string - Port string - PostsPerPage int - SSLCertificate string - SSLKey string - StaticPath string - Theme string - ThemePath string - Title string - SessionKey string - SessionName string - CookieDomain string - CookieMaxAge int - CookieSecure bool - CookieHTTPOnly bool + DatabasePath string + Description string + EnableCreate bool + EnableEdit bool + ExcerptWords int + GoogleAnalyticsCode string + Port string + PostsPerPage int + SSLCertificate string + SSLKey string + StaticPath string + Theme string + ThemePath string + Title string + SessionKey string + SessionName string + CookieDomain string + CookieMaxAge int + CookieSecure bool + CookieHTTPOnly bool } // DefaultConfiguration returns the default settings for the app func DefaultConfiguration() Configuration { - return Configuration{ - DatabasePath: os.Getenv("GOPATH") + "/data/journal.db", - Description: "A fantastic journal containing some thoughts, ideas and reflections", - EnableCreate: true, - EnableEdit: true, - ExcerptWords: 50, - GoogleAnalyticsCode: "", - Port: "3000", - PostsPerPage: 20, - SSLCertificate: "", - SSLKey: "", - StaticPath: "web/static", - Theme: "default", - ThemePath: "web/themes", - Title: "A Fantastic Journal", - SessionKey: "", - SessionName: "journal-session", - CookieDomain: "", - CookieMaxAge: 2592000, - CookieSecure: false, - CookieHTTPOnly: true, - } + return Configuration{ + DatabasePath: os.Getenv("GOPATH") + "/data/journal.db", + Description: "A fantastic journal containing some thoughts, ideas and reflections", + EnableCreate: true, + EnableEdit: true, + ExcerptWords: 50, + GoogleAnalyticsCode: "", + Port: "3000", + PostsPerPage: 20, + SSLCertificate: "", + SSLKey: "", + StaticPath: "web/static", + Theme: "default", + ThemePath: "web/themes", + Title: "A Fantastic Journal", + SessionKey: "", + SessionName: "journal-session", + CookieDomain: "", + CookieMaxAge: 2592000, + CookieSecure: false, + CookieHTTPOnly: true, + } } // ApplyEnvConfiguration applies the env variables on top of existing config // It first loads values from a .env file (if it exists), then applies any // environment variables set in the system (which override .env values) func ApplyEnvConfiguration(config *Configuration) { - // Parse .env file (if it exists) - dotenvVars, _ := env.Parse(".env") + // Parse .env file (if it exists) + dotenvVars, _ := env.Parse(".env") - // Helper function to get env var, preferring system env over .env file - getEnv := func(key string) string { - if val := os.Getenv(key); val != "" { - return val - } - return dotenvVars[key] - } + // Helper function to get env var, preferring system env over .env file + getEnv := func(key string) string { + if val := os.Getenv(key); val != "" { + return val + } + return dotenvVars[key] + } - // J_ARTICLES_PER_PAGE is deprecated, but it's checked first - articles, _ := strconv.Atoi(getEnv("J_ARTICLES_PER_PAGE")) - if articles > 0 { - config.PostsPerPage = articles - } - posts, _ := strconv.Atoi(getEnv("J_POSTS_PER_PAGE")) - if posts > 0 { - config.PostsPerPage = posts - } - database := getEnv("J_DB_PATH") - if database != "" { - config.DatabasePath = database - } - description := getEnv("J_DESCRIPTION") - if description != "" { - config.Description = description - } - enableCreate := getEnv("J_CREATE") - if enableCreate == "0" { - config.EnableCreate = false - } - enableEdit := getEnv("J_EDIT") - if enableEdit == "0" { - config.EnableEdit = false - } - excerptWords, _ := strconv.Atoi(getEnv("J_EXCERPT_WORDS")) - if excerptWords > 0 { - config.ExcerptWords = excerptWords - } - config.GoogleAnalyticsCode = getEnv("J_GA_CODE") - port := getEnv("J_PORT") - if port != "" { - config.Port = port - } + // J_ARTICLES_PER_PAGE is deprecated, but it's checked first + articles, _ := strconv.Atoi(getEnv("J_ARTICLES_PER_PAGE")) + if articles > 0 { + config.PostsPerPage = articles + } + posts, _ := strconv.Atoi(getEnv("J_POSTS_PER_PAGE")) + if posts > 0 { + config.PostsPerPage = posts + } + database := getEnv("J_DB_PATH") + if database != "" { + config.DatabasePath = database + } + description := getEnv("J_DESCRIPTION") + if description != "" { + config.Description = description + } + enableCreate := getEnv("J_CREATE") + if enableCreate == "0" { + config.EnableCreate = false + } + enableEdit := getEnv("J_EDIT") + if enableEdit == "0" { + config.EnableEdit = false + } + excerptWords, _ := strconv.Atoi(getEnv("J_EXCERPT_WORDS")) + if excerptWords > 0 { + config.ExcerptWords = excerptWords + } + config.GoogleAnalyticsCode = getEnv("J_GA_CODE") + port := getEnv("J_PORT") + if port != "" { + config.Port = port + } - config.SSLCertificate = getEnv("J_SSL_CERT") - config.SSLKey = getEnv("J_SSL_KEY") - staticPath := getEnv("J_STATIC_PATH") - if staticPath != "" { - config.StaticPath = staticPath - } - theme := getEnv("J_THEME") - if theme != "" { - config.Theme = theme - } - themePath := getEnv("J_THEME_PATH") - if themePath != "" { - config.ThemePath = themePath - } - title := getEnv("J_TITLE") - if title != "" { - config.Title = title - } + config.SSLCertificate = getEnv("J_SSL_CERT") + config.SSLKey = getEnv("J_SSL_KEY") + staticPath := getEnv("J_STATIC_PATH") + if staticPath != "" { + config.StaticPath = staticPath + } + theme := getEnv("J_THEME") + if theme != "" { + config.Theme = theme + } + themePath := getEnv("J_THEME_PATH") + if themePath != "" { + config.ThemePath = themePath + } + title := getEnv("J_TITLE") + if title != "" { + config.Title = title + } - sessionKey := getEnv("J_SESSION_KEY") - if sessionKey != "" { - if len(sessionKey) != 32 { - log.Println("WARNING: J_SESSION_KEY must be exactly 32 bytes. Using auto-generated key instead.") - sessionKey = "" - } - } - if sessionKey == "" { - bytes := make([]byte, 16) - if _, err := rand.Read(bytes); err == nil { - sessionKey = hex.EncodeToString(bytes) - log.Println("WARNING: J_SESSION_KEY not set or invalid. Using auto-generated key. Sessions will not persist across restarts.") - } - } - config.SessionKey = sessionKey + sessionKey := getEnv("J_SESSION_KEY") + if sessionKey != "" { + if len(sessionKey) != 32 { + log.Println("WARNING: J_SESSION_KEY must be exactly 32 bytes. Using auto-generated key instead.") + sessionKey = "" + } + } + if sessionKey == "" { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err == nil { + sessionKey = hex.EncodeToString(bytes) + log.Println("WARNING: J_SESSION_KEY not set or invalid. Using auto-generated key. Sessions will not persist across restarts.") + } + } + config.SessionKey = sessionKey - sessionName := getEnv("J_SESSION_NAME") - if sessionName != "" { - config.SessionName = sessionName - } - cookieDomain := getEnv("J_COOKIE_DOMAIN") - if cookieDomain != "" { - config.CookieDomain = cookieDomain - } - cookieMaxAge, _ := strconv.Atoi(getEnv("J_COOKIE_MAX_AGE")) - if cookieMaxAge > 0 { - config.CookieMaxAge = cookieMaxAge - } - cookieHTTPOnly := getEnv("J_COOKIE_HTTPONLY") - if cookieHTTPOnly == "0" || cookieHTTPOnly == "false" { - config.CookieHTTPOnly = false - } - if config.SSLCertificate != "" { - config.CookieSecure = true - } + sessionName := getEnv("J_SESSION_NAME") + if sessionName != "" { + config.SessionName = sessionName + } + cookieDomain := getEnv("J_COOKIE_DOMAIN") + if cookieDomain != "" { + config.CookieDomain = cookieDomain + } + cookieMaxAge, _ := strconv.Atoi(getEnv("J_COOKIE_MAX_AGE")) + if cookieMaxAge > 0 { + config.CookieMaxAge = cookieMaxAge + } + cookieHTTPOnly := getEnv("J_COOKIE_HTTPONLY") + if cookieHTTPOnly == "0" || cookieHTTPOnly == "false" { + config.CookieHTTPOnly = false + } + if config.SSLCertificate != "" { + config.CookieSecure = true + } } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a9978f2..7db32f9 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1,483 +1,483 @@ package app import ( - "os" - "path/filepath" - "testing" + "os" + "path/filepath" + "testing" ) func TestDefaultConfiguration(t *testing.T) { - config := DefaultConfiguration() - - if config.Port != "3000" { - t.Errorf("Expected Port '3000', got %q", config.Port) - } - if config.PostsPerPage != 20 { - t.Errorf("Expected PostsPerPage 20, got %d", config.PostsPerPage) - } - if config.SessionName != "journal-session" { - t.Errorf("Expected SessionName 'journal-session', got %q", config.SessionName) - } - if config.CookieMaxAge != 2592000 { - t.Errorf("Expected CookieMaxAge 2592000, got %d", config.CookieMaxAge) - } - if config.CookieHTTPOnly != true { - t.Errorf("Expected CookieHTTPOnly true, got %v", config.CookieHTTPOnly) - } - if config.CookieSecure != false { - t.Errorf("Expected CookieSecure false, got %v", config.CookieSecure) - } - if config.SessionKey != "" { - t.Errorf("Expected SessionKey to be empty by default, got %q", config.SessionKey) - } + config := DefaultConfiguration() + + if config.Port != "3000" { + t.Errorf("Expected Port '3000', got %q", config.Port) + } + if config.PostsPerPage != 20 { + t.Errorf("Expected PostsPerPage 20, got %d", config.PostsPerPage) + } + if config.SessionName != "journal-session" { + t.Errorf("Expected SessionName 'journal-session', got %q", config.SessionName) + } + if config.CookieMaxAge != 2592000 { + t.Errorf("Expected CookieMaxAge 2592000, got %d", config.CookieMaxAge) + } + if config.CookieHTTPOnly != true { + t.Errorf("Expected CookieHTTPOnly true, got %v", config.CookieHTTPOnly) + } + if config.CookieSecure != false { + t.Errorf("Expected CookieSecure false, got %v", config.CookieSecure) + } + if config.SessionKey != "" { + t.Errorf("Expected SessionKey to be empty by default, got %q", config.SessionKey) + } } func TestApplyEnvConfiguration_SessionKey(t *testing.T) { - tests := []struct { - name string - envValue string - expectWarning bool - expectKey bool - }{ - { - name: "Valid 32-byte key", - envValue: "12345678901234567890123456789012", - expectWarning: false, - expectKey: true, - }, - { - name: "Key too short generates auto key", - envValue: "tooshort", - expectWarning: true, - expectKey: true, - }, - { - name: "Key too long generates auto key", - envValue: "123456789012345678901234567890123", - expectWarning: true, - expectKey: true, - }, - { - name: "Empty key generates auto key", - envValue: "", - expectWarning: true, - expectKey: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - os.Setenv("J_SESSION_KEY", test.envValue) - defer os.Unsetenv("J_SESSION_KEY") - - config := DefaultConfiguration() - ApplyEnvConfiguration(&config) - - if test.expectKey && config.SessionKey == "" { - t.Errorf("Expected session key to be set") - } - if test.expectKey && len(config.SessionKey) != 32 { - t.Errorf("Expected session key length 32, got %d", len(config.SessionKey)) - } - if test.envValue != "" && len(test.envValue) == 32 && config.SessionKey != test.envValue { - t.Errorf("Expected session key %q, got %q", test.envValue, config.SessionKey) - } - }) - } + tests := []struct { + name string + envValue string + expectWarning bool + expectKey bool + }{ + { + name: "Valid 32-byte key", + envValue: "12345678901234567890123456789012", + expectWarning: false, + expectKey: true, + }, + { + name: "Key too short generates auto key", + envValue: "tooshort", + expectWarning: true, + expectKey: true, + }, + { + name: "Key too long generates auto key", + envValue: "123456789012345678901234567890123", + expectWarning: true, + expectKey: true, + }, + { + name: "Empty key generates auto key", + envValue: "", + expectWarning: true, + expectKey: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + os.Setenv("J_SESSION_KEY", test.envValue) + defer os.Unsetenv("J_SESSION_KEY") + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if test.expectKey && config.SessionKey == "" { + t.Errorf("Expected session key to be set") + } + if test.expectKey && len(config.SessionKey) != 32 { + t.Errorf("Expected session key length 32, got %d", len(config.SessionKey)) + } + if test.envValue != "" && len(test.envValue) == 32 && config.SessionKey != test.envValue { + t.Errorf("Expected session key %q, got %q", test.envValue, config.SessionKey) + } + }) + } } func TestApplyEnvConfiguration_SessionName(t *testing.T) { - tests := []struct { - name string - envValue string - expected string - }{ - { - name: "Custom session name", - envValue: "custom-session", - expected: "custom-session", - }, - { - name: "Empty uses default", - envValue: "", - expected: "journal-session", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if test.envValue != "" { - os.Setenv("J_SESSION_NAME", test.envValue) - defer os.Unsetenv("J_SESSION_NAME") - } - - config := DefaultConfiguration() - ApplyEnvConfiguration(&config) - - if config.SessionName != test.expected { - t.Errorf("Expected SessionName %q, got %q", test.expected, config.SessionName) - } - }) - } + tests := []struct { + name string + envValue string + expected string + }{ + { + name: "Custom session name", + envValue: "custom-session", + expected: "custom-session", + }, + { + name: "Empty uses default", + envValue: "", + expected: "journal-session", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_SESSION_NAME", test.envValue) + defer os.Unsetenv("J_SESSION_NAME") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.SessionName != test.expected { + t.Errorf("Expected SessionName %q, got %q", test.expected, config.SessionName) + } + }) + } } func TestApplyEnvConfiguration_CookieDomain(t *testing.T) { - tests := []struct { - name string - envValue string - expected string - }{ - { - name: "Custom domain", - envValue: ".example.com", - expected: ".example.com", - }, - { - name: "Specific subdomain", - envValue: "app.example.com", - expected: "app.example.com", - }, - { - name: "Empty uses default", - envValue: "", - expected: "", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if test.envValue != "" { - os.Setenv("J_COOKIE_DOMAIN", test.envValue) - defer os.Unsetenv("J_COOKIE_DOMAIN") - } - - config := DefaultConfiguration() - ApplyEnvConfiguration(&config) - - if config.CookieDomain != test.expected { - t.Errorf("Expected CookieDomain %q, got %q", test.expected, config.CookieDomain) - } - }) - } + tests := []struct { + name string + envValue string + expected string + }{ + { + name: "Custom domain", + envValue: ".example.com", + expected: ".example.com", + }, + { + name: "Specific subdomain", + envValue: "app.example.com", + expected: "app.example.com", + }, + { + name: "Empty uses default", + envValue: "", + expected: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_COOKIE_DOMAIN", test.envValue) + defer os.Unsetenv("J_COOKIE_DOMAIN") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieDomain != test.expected { + t.Errorf("Expected CookieDomain %q, got %q", test.expected, config.CookieDomain) + } + }) + } } func TestApplyEnvConfiguration_CookieMaxAge(t *testing.T) { - tests := []struct { - name string - envValue string - expected int - }{ - { - name: "Custom max age", - envValue: "7200", - expected: 7200, - }, - { - name: "One week", - envValue: "604800", - expected: 604800, - }, - { - name: "Invalid uses default", - envValue: "invalid", - expected: 2592000, - }, - { - name: "Empty uses default", - envValue: "", - expected: 2592000, - }, - { - name: "Zero uses default", - envValue: "0", - expected: 2592000, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if test.envValue != "" { - os.Setenv("J_COOKIE_MAX_AGE", test.envValue) - defer os.Unsetenv("J_COOKIE_MAX_AGE") - } - - config := DefaultConfiguration() - ApplyEnvConfiguration(&config) - - if config.CookieMaxAge != test.expected { - t.Errorf("Expected CookieMaxAge %d, got %d", test.expected, config.CookieMaxAge) - } - }) - } + tests := []struct { + name string + envValue string + expected int + }{ + { + name: "Custom max age", + envValue: "7200", + expected: 7200, + }, + { + name: "One week", + envValue: "604800", + expected: 604800, + }, + { + name: "Invalid uses default", + envValue: "invalid", + expected: 2592000, + }, + { + name: "Empty uses default", + envValue: "", + expected: 2592000, + }, + { + name: "Zero uses default", + envValue: "0", + expected: 2592000, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_COOKIE_MAX_AGE", test.envValue) + defer os.Unsetenv("J_COOKIE_MAX_AGE") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieMaxAge != test.expected { + t.Errorf("Expected CookieMaxAge %d, got %d", test.expected, config.CookieMaxAge) + } + }) + } } func TestApplyEnvConfiguration_CookieHTTPOnly(t *testing.T) { - tests := []struct { - name string - envValue string - expected bool - }{ - { - name: "Disabled with 0", - envValue: "0", - expected: false, - }, - { - name: "Disabled with false", - envValue: "false", - expected: false, - }, - { - name: "Enabled with 1", - envValue: "1", - expected: true, - }, - { - name: "Enabled with true", - envValue: "true", - expected: true, - }, - { - name: "Default is enabled", - envValue: "", - expected: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if test.envValue != "" { - os.Setenv("J_COOKIE_HTTPONLY", test.envValue) - defer os.Unsetenv("J_COOKIE_HTTPONLY") - } - - config := DefaultConfiguration() - ApplyEnvConfiguration(&config) - - if config.CookieHTTPOnly != test.expected { - t.Errorf("Expected CookieHTTPOnly %v, got %v", test.expected, config.CookieHTTPOnly) - } - }) - } + tests := []struct { + name string + envValue string + expected bool + }{ + { + name: "Disabled with 0", + envValue: "0", + expected: false, + }, + { + name: "Disabled with false", + envValue: "false", + expected: false, + }, + { + name: "Enabled with 1", + envValue: "1", + expected: true, + }, + { + name: "Enabled with true", + envValue: "true", + expected: true, + }, + { + name: "Default is enabled", + envValue: "", + expected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.envValue != "" { + os.Setenv("J_COOKIE_HTTPONLY", test.envValue) + defer os.Unsetenv("J_COOKIE_HTTPONLY") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieHTTPOnly != test.expected { + t.Errorf("Expected CookieHTTPOnly %v, got %v", test.expected, config.CookieHTTPOnly) + } + }) + } } func TestApplyEnvConfiguration_CookieSecure(t *testing.T) { - tests := []struct { - name string - sslCert string - sslKey string - expected bool - description string - }{ - { - name: "Secure when SSL cert is set", - sslCert: "/path/to/cert.pem", - sslKey: "/path/to/key.pem", - expected: true, - description: "Cookie should be secure when SSL is enabled", - }, - { - name: "Not secure when SSL cert is empty", - sslCert: "", - sslKey: "", - expected: false, - description: "Cookie should not be secure when SSL is not enabled", - }, - { - name: "Secure even without key if cert is set", - sslCert: "/path/to/cert.pem", - sslKey: "", - expected: true, - description: "Cookie secure flag follows cert presence", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if test.sslCert != "" { - os.Setenv("J_SSL_CERT", test.sslCert) - defer os.Unsetenv("J_SSL_CERT") - } - if test.sslKey != "" { - os.Setenv("J_SSL_KEY", test.sslKey) - defer os.Unsetenv("J_SSL_KEY") - } - - config := DefaultConfiguration() - ApplyEnvConfiguration(&config) - - if config.CookieSecure != test.expected { - t.Errorf("%s: Expected CookieSecure %v, got %v", test.description, test.expected, config.CookieSecure) - } - }) - } + tests := []struct { + name string + sslCert string + sslKey string + expected bool + description string + }{ + { + name: "Secure when SSL cert is set", + sslCert: "/path/to/cert.pem", + sslKey: "/path/to/key.pem", + expected: true, + description: "Cookie should be secure when SSL is enabled", + }, + { + name: "Not secure when SSL cert is empty", + sslCert: "", + sslKey: "", + expected: false, + description: "Cookie should not be secure when SSL is not enabled", + }, + { + name: "Secure even without key if cert is set", + sslCert: "/path/to/cert.pem", + sslKey: "", + expected: true, + description: "Cookie secure flag follows cert presence", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.sslCert != "" { + os.Setenv("J_SSL_CERT", test.sslCert) + defer os.Unsetenv("J_SSL_CERT") + } + if test.sslKey != "" { + os.Setenv("J_SSL_KEY", test.sslKey) + defer os.Unsetenv("J_SSL_KEY") + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.CookieSecure != test.expected { + t.Errorf("%s: Expected CookieSecure %v, got %v", test.description, test.expected, config.CookieSecure) + } + }) + } } func TestApplyEnvConfiguration_Combined(t *testing.T) { - os.Setenv("J_SESSION_KEY", "abcdefghijklmnopqrstuvwxyz123456") - os.Setenv("J_SESSION_NAME", "my-app-session") - os.Setenv("J_COOKIE_DOMAIN", ".myapp.com") - os.Setenv("J_COOKIE_MAX_AGE", "1800") - os.Setenv("J_COOKIE_HTTPONLY", "0") - os.Setenv("J_SSL_CERT", "/path/to/cert.pem") - os.Setenv("J_PORT", "8080") - defer func() { - os.Unsetenv("J_SESSION_KEY") - os.Unsetenv("J_SESSION_NAME") - os.Unsetenv("J_COOKIE_DOMAIN") - os.Unsetenv("J_COOKIE_MAX_AGE") - os.Unsetenv("J_COOKIE_HTTPONLY") - os.Unsetenv("J_SSL_CERT") - os.Unsetenv("J_PORT") - }() - - config := DefaultConfiguration() - ApplyEnvConfiguration(&config) - - if config.SessionKey != "abcdefghijklmnopqrstuvwxyz123456" { - t.Errorf("Expected SessionKey 'abcdefghijklmnopqrstuvwxyz123456', got %q", config.SessionKey) - } - if config.SessionName != "my-app-session" { - t.Errorf("Expected SessionName 'my-app-session', got %q", config.SessionName) - } - if config.CookieDomain != ".myapp.com" { - t.Errorf("Expected CookieDomain '.myapp.com', got %q", config.CookieDomain) - } - if config.CookieMaxAge != 1800 { - t.Errorf("Expected CookieMaxAge 1800, got %d", config.CookieMaxAge) - } - if config.CookieHTTPOnly != false { - t.Errorf("Expected CookieHTTPOnly false, got %v", config.CookieHTTPOnly) - } - if config.CookieSecure != true { - t.Errorf("Expected CookieSecure true (SSL enabled), got %v", config.CookieSecure) - } - if config.Port != "8080" { - t.Errorf("Expected Port '8080', got %q", config.Port) - } + os.Setenv("J_SESSION_KEY", "abcdefghijklmnopqrstuvwxyz123456") + os.Setenv("J_SESSION_NAME", "my-app-session") + os.Setenv("J_COOKIE_DOMAIN", ".myapp.com") + os.Setenv("J_COOKIE_MAX_AGE", "1800") + os.Setenv("J_COOKIE_HTTPONLY", "0") + os.Setenv("J_SSL_CERT", "/path/to/cert.pem") + os.Setenv("J_PORT", "8080") + defer func() { + os.Unsetenv("J_SESSION_KEY") + os.Unsetenv("J_SESSION_NAME") + os.Unsetenv("J_COOKIE_DOMAIN") + os.Unsetenv("J_COOKIE_MAX_AGE") + os.Unsetenv("J_COOKIE_HTTPONLY") + os.Unsetenv("J_SSL_CERT") + os.Unsetenv("J_PORT") + }() + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.SessionKey != "abcdefghijklmnopqrstuvwxyz123456" { + t.Errorf("Expected SessionKey 'abcdefghijklmnopqrstuvwxyz123456', got %q", config.SessionKey) + } + if config.SessionName != "my-app-session" { + t.Errorf("Expected SessionName 'my-app-session', got %q", config.SessionName) + } + if config.CookieDomain != ".myapp.com" { + t.Errorf("Expected CookieDomain '.myapp.com', got %q", config.CookieDomain) + } + if config.CookieMaxAge != 1800 { + t.Errorf("Expected CookieMaxAge 1800, got %d", config.CookieMaxAge) + } + if config.CookieHTTPOnly != false { + t.Errorf("Expected CookieHTTPOnly false, got %v", config.CookieHTTPOnly) + } + if config.CookieSecure != true { + t.Errorf("Expected CookieSecure true (SSL enabled), got %v", config.CookieSecure) + } + if config.Port != "8080" { + t.Errorf("Expected Port '8080', got %q", config.Port) + } } func TestApplyEnvConfiguration_DotEnvFile(t *testing.T) { - // Save current working directory - originalWd, _ := os.Getwd() - defer os.Chdir(originalWd) + // Save current working directory + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) - // Create a temporary directory for testing - tmpDir := t.TempDir() - os.Chdir(tmpDir) + // Create a temporary directory for testing + tmpDir := t.TempDir() + os.Chdir(tmpDir) - // Create a .env file - envContent := `J_PORT=9000 + // Create a .env file + envContent := `J_PORT=9000 J_TITLE=Test Journal J_DESCRIPTION=A test journal J_ARTICLES_PER_PAGE=15 J_COOKIE_MAX_AGE=3600 ` - if err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644); err != nil { - t.Fatalf("Failed to create .env file: %v", err) - } - - config := DefaultConfiguration() - ApplyEnvConfiguration(&config) - - if config.Port != "9000" { - t.Errorf("Expected Port '9000' from .env, got %q", config.Port) - } - if config.Title != "Test Journal" { - t.Errorf("Expected Title 'Test Journal' from .env, got %q", config.Title) - } - if config.Description != "A test journal" { - t.Errorf("Expected Description 'A test journal' from .env, got %q", config.Description) - } - if config.PostsPerPage != 15 { - t.Errorf("Expected PostsPerPage 15 from .env, got %d", config.PostsPerPage) - } - if config.CookieMaxAge != 3600 { - t.Errorf("Expected CookieMaxAge 3600 from .env, got %d", config.CookieMaxAge) - } + if err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644); err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.Port != "9000" { + t.Errorf("Expected Port '9000' from .env, got %q", config.Port) + } + if config.Title != "Test Journal" { + t.Errorf("Expected Title 'Test Journal' from .env, got %q", config.Title) + } + if config.Description != "A test journal" { + t.Errorf("Expected Description 'A test journal' from .env, got %q", config.Description) + } + if config.PostsPerPage != 15 { + t.Errorf("Expected PostsPerPage 15 from .env, got %d", config.PostsPerPage) + } + if config.CookieMaxAge != 3600 { + t.Errorf("Expected CookieMaxAge 3600 from .env, got %d", config.CookieMaxAge) + } } func TestApplyEnvConfiguration_EnvOverridesDotEnv(t *testing.T) { - // Save current working directory and environment - originalWd, _ := os.Getwd() - defer os.Chdir(originalWd) - defer os.Unsetenv("J_PORT") - defer os.Unsetenv("J_TITLE") - - // Create a temporary directory for testing - tmpDir := t.TempDir() - os.Chdir(tmpDir) - - // Create a .env file - envContent := `J_PORT=9000 + // Save current working directory and environment + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + defer os.Unsetenv("J_PORT") + defer os.Unsetenv("J_TITLE") + + // Create a temporary directory for testing + tmpDir := t.TempDir() + os.Chdir(tmpDir) + + // Create a .env file + envContent := `J_PORT=9000 J_TITLE=DotEnv Title J_DESCRIPTION=DotEnv Description ` - if err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644); err != nil { - t.Fatalf("Failed to create .env file: %v", err) - } - - // Set environment variables that should override .env - os.Setenv("J_PORT", "7777") - os.Setenv("J_TITLE", "Override Title") - - config := DefaultConfiguration() - ApplyEnvConfiguration(&config) - - // Environment variables should override .env values - if config.Port != "7777" { - t.Errorf("Expected Port '7777' from env var (not .env), got %q", config.Port) - } - if config.Title != "Override Title" { - t.Errorf("Expected Title 'Override Title' from env var (not .env), got %q", config.Title) - } - // Values not overridden should come from .env - if config.Description != "DotEnv Description" { - t.Errorf("Expected Description 'DotEnv Description' from .env, got %q", config.Description) - } + if err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644); err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + // Set environment variables that should override .env + os.Setenv("J_PORT", "7777") + os.Setenv("J_TITLE", "Override Title") + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + // Environment variables should override .env values + if config.Port != "7777" { + t.Errorf("Expected Port '7777' from env var (not .env), got %q", config.Port) + } + if config.Title != "Override Title" { + t.Errorf("Expected Title 'Override Title' from env var (not .env), got %q", config.Title) + } + // Values not overridden should come from .env + if config.Description != "DotEnv Description" { + t.Errorf("Expected Description 'DotEnv Description' from .env, got %q", config.Description) + } } func TestApplyEnvConfiguration_NoDotEnvFile(t *testing.T) { - // Save current working directory - originalWd, _ := os.Getwd() - defer os.Chdir(originalWd) - - // Create a temporary directory without .env file - tmpDir := t.TempDir() - os.Chdir(tmpDir) - - // Should work fine even without .env file - config := DefaultConfiguration() - ApplyEnvConfiguration(&config) - - // Should have default values - if config.Port != "3000" { - t.Errorf("Expected default Port '3000', got %q", config.Port) - } + // Save current working directory + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a temporary directory without .env file + tmpDir := t.TempDir() + os.Chdir(tmpDir) + + // Should work fine even without .env file + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + // Should have default values + if config.Port != "3000" { + t.Errorf("Expected default Port '3000', got %q", config.Port) + } } func TestApplyEnvConfiguration_ArticlesDeprecated(t *testing.T) { - // Save current working directory - originalWd, _ := os.Getwd() - defer os.Chdir(originalWd) + // Save current working directory + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) - // Create a temporary directory for testing - tmpDir := t.TempDir() - os.Chdir(tmpDir) + // Create a temporary directory for testing + tmpDir := t.TempDir() + os.Chdir(tmpDir) - // Create a .env file - envContent := ` + // Create a .env file + envContent := ` J_POSTS_PER_PAGE=15 J_ARTICLES_PER_PAGE=10 ` - if err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644); err != nil { - t.Fatalf("Failed to create .env file: %v", err) - } + if err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644); err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } - config := DefaultConfiguration() - ApplyEnvConfiguration(&config) + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) - if config.PostsPerPage != 15 { - t.Errorf("Expected PostsPerPage 15 from .env, got %d", config.PostsPerPage) - } + if config.PostsPerPage != 15 { + t.Errorf("Expected PostsPerPage 15 from .env, got %d", config.PostsPerPage) + } } diff --git a/internal/app/controller/apiv1/create.go b/internal/app/controller/apiv1/create.go index bfea912..d0dd560 100644 --- a/internal/app/controller/apiv1/create.go +++ b/internal/app/controller/apiv1/create.go @@ -1,43 +1,43 @@ package apiv1 import ( - "encoding/json" - "net/http" + "encoding/json" + "net/http" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" ) // Create Create a new entry via API type Create struct { - controller.Super + controller.Super } // Run Create action func (c *Create) Run(response http.ResponseWriter, request *http.Request) { - container := c.Super.Container().(*app.Container) - if !container.Configuration.EnableCreate { - response.WriteHeader(http.StatusForbidden) - return - } + container := c.Super.Container().(*app.Container) + if !container.Configuration.EnableCreate { + response.WriteHeader(http.StatusForbidden) + return + } - decoder := json.NewDecoder(request.Body) - var journalRequest = journalFromJSON{} - err := decoder.Decode(&journalRequest) - if err != nil { - response.WriteHeader(http.StatusBadRequest) - } else { - if !model.Validate(journalRequest.Title, journalRequest.Date, journalRequest.Content) { - response.WriteHeader(http.StatusBadRequest) - } else { - journal := model.Journal{ID: 0, Slug: model.Slugify(journalRequest.Title), Title: journalRequest.Title, Date: journalRequest.Date, Content: journalRequest.Content} - js := model.Journals{Container: container} - journal = js.Save(journal) - response.WriteHeader(http.StatusCreated) - encoder := json.NewEncoder(response) - encoder.SetEscapeHTML(false) - encoder.Encode(MapJournalToJSON(journal)) - } - } + decoder := json.NewDecoder(request.Body) + var journalRequest = journalFromJSON{} + err := decoder.Decode(&journalRequest) + if err != nil { + response.WriteHeader(http.StatusBadRequest) + } else { + if !model.Validate(journalRequest.Title, journalRequest.Date, journalRequest.Content) { + response.WriteHeader(http.StatusBadRequest) + } else { + journal := model.Journal{ID: 0, Slug: model.Slugify(journalRequest.Title), Title: journalRequest.Title, Date: journalRequest.Date, Content: journalRequest.Content} + js := model.Journals{Container: container} + journal = js.Save(journal) + response.WriteHeader(http.StatusCreated) + encoder := json.NewEncoder(response) + encoder.SetEscapeHTML(false) + encoder.Encode(MapJournalToJSON(journal)) + } + } } diff --git a/internal/app/controller/apiv1/create_test.go b/internal/app/controller/apiv1/create_test.go index ccae62d..4ee6d32 100644 --- a/internal/app/controller/apiv1/create_test.go +++ b/internal/app/controller/apiv1/create_test.go @@ -1,69 +1,69 @@ package apiv1 import ( - "net/http" - "strings" - "testing" + "net/http" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestCreate_Run(t *testing.T) { - db := &database.MockSqlite{} - db.Result = &database.MockResult{} - db.Rows = &database.MockRowsEmpty{} - container := &app.Container{Configuration: app.DefaultConfiguration(), Db: db} - response := controller.NewMockResponse() - response.Reset() - controller := &Create{} - controller.DisableTracking() + db := &database.MockSqlite{} + db.Result = &database.MockResult{} + db.Rows = &database.MockRowsEmpty{} + container := &app.Container{Configuration: app.DefaultConfiguration(), Db: db} + response := controller.NewMockResponse() + response.Reset() + controller := &Create{} + controller.DisableTracking() - // Test forbidden - container.Configuration.EnableCreate = false - request, _ := http.NewRequest("POST", "/new", strings.NewReader("{\"not\":\"valid\":\"json\"}")) - request.Header.Add("Content-Type", "application/json") - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if response.StatusCode != 403 { - t.Error("Expected 403 error when creation is disabled") - } + // Test forbidden + container.Configuration.EnableCreate = false + request, _ := http.NewRequest("POST", "/new", strings.NewReader("{\"not\":\"valid\":\"json\"}")) + request.Header.Add("Content-Type", "application/json") + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if response.StatusCode != 403 { + t.Error("Expected 403 error when creation is disabled") + } - // Test invalid JSON - container.Configuration.EnableCreate = true - request, _ = http.NewRequest("POST", "/new", strings.NewReader("{\"not\":\"valid\":\"json\"}")) - request.Header.Add("Content-Type", "application/json") - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if response.StatusCode != 400 { - t.Error("Expected 400 error when invalid JSON provided") - } + // Test invalid JSON + container.Configuration.EnableCreate = true + request, _ = http.NewRequest("POST", "/new", strings.NewReader("{\"not\":\"valid\":\"json\"}")) + request.Header.Add("Content-Type", "application/json") + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if response.StatusCode != 400 { + t.Error("Expected 400 error when invalid JSON provided") + } - // Test missing JSON - request, _ = http.NewRequest("POST", "/new", strings.NewReader("{\"title\":\"only\"}")) - request.Header.Add("Content-Type", "application/json") - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if response.StatusCode != 400 { - t.Error("Expected 400 error when missing JSON provided") - } + // Test missing JSON + request, _ = http.NewRequest("POST", "/new", strings.NewReader("{\"title\":\"only\"}")) + request.Header.Add("Content-Type", "application/json") + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if response.StatusCode != 400 { + t.Error("Expected 400 error when missing JSON provided") + } - // Test Journal is retrieved on save - response.Reset() - request, _ = http.NewRequest("POST", "/new", strings.NewReader("{\"title\":\"Something New\",\"date\":\"2018-01-01\",\"content\":\"New\"}")) - request.Header.Add("Content-Type", "application/json") - db.Result = &database.MockResult{} - controller.Run(response, request) - if response.StatusCode != 201 || !strings.Contains(response.Content, "Something New") { - t.Error("Expected new title to be within content") - } + // Test Journal is retrieved on save + response.Reset() + request, _ = http.NewRequest("POST", "/new", strings.NewReader("{\"title\":\"Something New\",\"date\":\"2018-01-01\",\"content\":\"New\"}")) + request.Header.Add("Content-Type", "application/json") + db.Result = &database.MockResult{} + controller.Run(response, request) + 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") - } + // 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 58fc1e9..2ee55a3 100644 --- a/internal/app/controller/apiv1/data.go +++ b/internal/app/controller/apiv1/data.go @@ -3,45 +3,45 @@ package apiv1 import "github.com/jamiefdhurst/journal/internal/app/model" type journalFromJSON struct { - Title string - Date string - Content string + Title string + Date string + Content string } type journalToJSON struct { - 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"` + 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 { - result := journalToJSON{ - URL: "/api/v1/post/" + journal.Slug, - Title: journal.Title, - Date: journal.GetEditableDate(), - Content: journal.Content, - } + result := journalToJSON{ + URL: "/api/v1/post/" + journal.Slug, + Title: journal.Title, + Date: journal.GetEditableDate(), + 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 - } + // 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 + return result } func MapJournalsToJSON(journals []model.Journal) []journalToJSON { - result := make([]journalToJSON, len(journals)) - for i, j := range journals { - result[i] = MapJournalToJSON(j) - } - return result + result := make([]journalToJSON, len(journals)) + for i, j := range journals { + result[i] = MapJournalToJSON(j) + } + return result } diff --git a/internal/app/controller/apiv1/list.go b/internal/app/controller/apiv1/list.go index 30a0f21..c968b77 100644 --- a/internal/app/controller/apiv1/list.go +++ b/internal/app/controller/apiv1/list.go @@ -1,50 +1,50 @@ package apiv1 import ( - "encoding/json" - "net/http" - "strconv" - - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" - "github.com/jamiefdhurst/journal/pkg/database" + "encoding/json" + "net/http" + "strconv" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/pkg/database" ) type listResponse struct { - Links database.PaginationLinks `json:"links"` - Pagination database.PaginationInformation `json:"pagination"` - Posts []journalToJSON `json:"posts"` + Links database.PaginationLinks `json:"links"` + Pagination database.PaginationInformation `json:"pagination"` + Posts []journalToJSON `json:"posts"` } // List Display all blog entries as JSON type List struct { - controller.Super + controller.Super } func ListData(request *http.Request, js model.Journals) ([]model.Journal, database.PaginationInformation) { - paginationQuery := database.PaginationQuery{Page: 1, ResultsPerPage: js.Container.Configuration.PostsPerPage} - query := request.URL.Query() - if query["page"] != nil { - page, err := strconv.Atoi(query["page"][0]) - if err == nil { - paginationQuery.Page = page - } - } - - return js.FetchPaginated(paginationQuery) + paginationQuery := database.PaginationQuery{Page: 1, ResultsPerPage: js.Container.Configuration.PostsPerPage} + query := request.URL.Query() + if query["page"] != nil { + page, err := strconv.Atoi(query["page"][0]) + if err == nil { + paginationQuery.Page = page + } + } + + return js.FetchPaginated(paginationQuery) } // Run List action func (c *List) Run(response http.ResponseWriter, request *http.Request) { - container := c.Super.Container().(*app.Container) - js := model.Journals{Container: container} + container := c.Super.Container().(*app.Container) + js := model.Journals{Container: container} - journals, paginationInfo := ListData(request, js) - jsonResponse := listResponse{database.LinksPagination("/api/v1/post", paginationInfo), paginationInfo, MapJournalsToJSON(journals)} + journals, paginationInfo := ListData(request, js) + jsonResponse := listResponse{database.LinksPagination("/api/v1/post", paginationInfo), paginationInfo, MapJournalsToJSON(journals)} - response.Header().Add("Content-Type", "application/json") - encoder := json.NewEncoder(response) - encoder.SetEscapeHTML(false) - encoder.Encode(jsonResponse) + response.Header().Add("Content-Type", "application/json") + encoder := json.NewEncoder(response) + encoder.SetEscapeHTML(false) + encoder.Encode(jsonResponse) } diff --git a/internal/app/controller/apiv1/list_test.go b/internal/app/controller/apiv1/list_test.go index 220a3e3..706246e 100644 --- a/internal/app/controller/apiv1/list_test.go +++ b/internal/app/controller/apiv1/list_test.go @@ -1,31 +1,31 @@ package apiv1 import ( - "net/http" - "strings" - "testing" + "net/http" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestList_Run(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Configuration: app.DefaultConfiguration(), Db: db} - response := &controller.MockResponse{} - response.Reset() - controller := &List{} - controller.DisableTracking() + db := &database.MockSqlite{} + container := &app.Container{Configuration: app.DefaultConfiguration(), Db: db} + response := &controller.MockResponse{} + response.Reset() + controller := &List{} + controller.DisableTracking() - // Test showing all Journals - db.EnableMultiMode() - db.AppendResult(&database.MockPagination_Result{TotalResults: 2}) - db.AppendResult(&database.MockJournal_MultipleRows{}) - request, _ := http.NewRequest("GET", "/", strings.NewReader("")) - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if !strings.Contains(response.Content, "Title 2") { - t.Error("Expected all journals to be returned") - } + // Test showing all Journals + db.EnableMultiMode() + db.AppendResult(&database.MockPagination_Result{TotalResults: 2}) + db.AppendResult(&database.MockJournal_MultipleRows{}) + request, _ := http.NewRequest("GET", "/", strings.NewReader("")) + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if !strings.Contains(response.Content, "Title 2") { + t.Error("Expected all journals to be returned") + } } diff --git a/internal/app/controller/apiv1/random.go b/internal/app/controller/apiv1/random.go index f3f4283..00e488c 100644 --- a/internal/app/controller/apiv1/random.go +++ b/internal/app/controller/apiv1/random.go @@ -1,37 +1,37 @@ package apiv1 import ( - "encoding/json" - "net/http" + "encoding/json" + "net/http" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" ) // Random Controller to handle returning a random journal entry via API type Random struct { - controller.Super + controller.Super } // Run Random controller action func (c *Random) Run(response http.ResponseWriter, request *http.Request) { - container := c.Super.Container().(*app.Container) - js := model.Journals{Container: container} + container := c.Super.Container().(*app.Container) + js := model.Journals{Container: container} - // Find a random journal entry - randomJournal := js.FindRandom() + // Find a random journal entry + randomJournal := js.FindRandom() - // Set content type to JSON - response.Header().Set("Content-Type", "application/json") + // Set content type to JSON + response.Header().Set("Content-Type", "application/json") - // Return 404 if no journal was found - if randomJournal.ID == 0 { - response.WriteHeader(http.StatusNotFound) - return - } + // Return 404 if no journal was found + if randomJournal.ID == 0 { + response.WriteHeader(http.StatusNotFound) + return + } - // Encode and return the journal - encoder := json.NewEncoder(response) - encoder.Encode(MapJournalToJSON(randomJournal)) + // Encode and return the journal + encoder := json.NewEncoder(response) + encoder.Encode(MapJournalToJSON(randomJournal)) } diff --git a/internal/app/controller/apiv1/random_test.go b/internal/app/controller/apiv1/random_test.go index 59c0acd..43426e4 100644 --- a/internal/app/controller/apiv1/random_test.go +++ b/internal/app/controller/apiv1/random_test.go @@ -1,53 +1,53 @@ package apiv1 import ( - "net/http" - "strings" - "testing" + "net/http" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestRandom_Run(t *testing.T) { - response := controller.NewMockResponse() - db := &database.MockSqlite{} - container := &app.Container{Db: db} - random := &Random{} - random.DisableTracking() - - // Test with a journal found - db.Rows = &database.MockJournal_SingleRow{} - request, _ := http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) - random.Init(container, []string{}, request) - response.StatusCode = http.StatusOK // Set a status code since our mock doesn't - response.Headers.Set("Content-Type", "application/json") - response.Content = `{"id":1,"slug":"slug","title":"Title","date":"2018-02-01","content":"Content"}` - random.Run(response, request) - - if response.StatusCode != http.StatusOK { - t.Errorf("Expected OK, got status %d", response.StatusCode) - } - - if contentType := response.Headers.Get("Content-Type"); contentType != "application/json" { - t.Errorf("Expected json content type, got %s", contentType) - } - - // In a real test, we would decode the JSON response, but we're mocking it - // with a hard-coded valid response, so we can just check that we have content - if response.Content == "" { - t.Error("Expected JSON response content, got empty response") - } - - // Test with no journal found - response = controller.NewMockResponse() - db.Rows = &database.MockRowsEmpty{} - request, _ = http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) - random.Init(container, []string{}, request) - random.Run(response, request) - - if response.StatusCode != http.StatusNotFound { - t.Errorf("Expected not found, got status %d", response.StatusCode) - } + response := controller.NewMockResponse() + db := &database.MockSqlite{} + container := &app.Container{Db: db} + random := &Random{} + random.DisableTracking() + + // Test with a journal found + db.Rows = &database.MockJournal_SingleRow{} + request, _ := http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) + random.Init(container, []string{}, request) + response.StatusCode = http.StatusOK // Set a status code since our mock doesn't + response.Headers.Set("Content-Type", "application/json") + response.Content = `{"id":1,"slug":"slug","title":"Title","date":"2018-02-01","content":"Content"}` + random.Run(response, request) + + if response.StatusCode != http.StatusOK { + t.Errorf("Expected OK, got status %d", response.StatusCode) + } + + if contentType := response.Headers.Get("Content-Type"); contentType != "application/json" { + t.Errorf("Expected json content type, got %s", contentType) + } + + // In a real test, we would decode the JSON response, but we're mocking it + // with a hard-coded valid response, so we can just check that we have content + if response.Content == "" { + t.Error("Expected JSON response content, got empty response") + } + + // Test with no journal found + response = controller.NewMockResponse() + db.Rows = &database.MockRowsEmpty{} + request, _ = http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) + random.Init(container, []string{}, request) + random.Run(response, request) + + if response.StatusCode != http.StatusNotFound { + t.Errorf("Expected not found, got status %d", response.StatusCode) + } } diff --git a/internal/app/controller/apiv1/single.go b/internal/app/controller/apiv1/single.go index e0743bb..eab073b 100644 --- a/internal/app/controller/apiv1/single.go +++ b/internal/app/controller/apiv1/single.go @@ -1,32 +1,32 @@ package apiv1 import ( - "encoding/json" - "net/http" + "encoding/json" + "net/http" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" ) // Single Find and display single blog entry type Single struct { - controller.Super + controller.Super } // Run Single action func (c *Single) Run(response http.ResponseWriter, request *http.Request) { - js := model.Journals{Container: c.Super.Container().(*app.Container)} - journal := js.FindBySlug(c.Params()[1]) + js := model.Journals{Container: c.Super.Container().(*app.Container)} + journal := js.FindBySlug(c.Params()[1]) - response.Header().Add("Content-Type", "application/json") - if journal.ID == 0 { - response.WriteHeader(http.StatusNotFound) - } else { - encoder := json.NewEncoder(response) - encoder.SetEscapeHTML(false) - encoder.Encode(MapJournalToJSON(journal)) - } + response.Header().Add("Content-Type", "application/json") + if journal.ID == 0 { + response.WriteHeader(http.StatusNotFound) + } else { + encoder := json.NewEncoder(response) + encoder.SetEscapeHTML(false) + encoder.Encode(MapJournalToJSON(journal)) + } } diff --git a/internal/app/controller/apiv1/single_test.go b/internal/app/controller/apiv1/single_test.go index 6952f05..2c404fb 100644 --- a/internal/app/controller/apiv1/single_test.go +++ b/internal/app/controller/apiv1/single_test.go @@ -1,39 +1,39 @@ package apiv1 import ( - "net/http" - "strings" - "testing" + "net/http" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestSingle_Run(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - response := &controller.MockResponse{} - response.Reset() - controller := &Single{} - controller.DisableTracking() + db := &database.MockSqlite{} + container := &app.Container{Db: db} + response := &controller.MockResponse{} + response.Reset() + controller := &Single{} + controller.DisableTracking() - // Test not found/error with GET - db.Rows = &database.MockRowsEmpty{} - request := &http.Request{Method: "GET"} - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if response.StatusCode != 404 { - t.Error("Expected 404 error when journal not found") - } + // Test not found/error with GET + db.Rows = &database.MockRowsEmpty{} + request := &http.Request{Method: "GET"} + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if response.StatusCode != 404 { + t.Error("Expected 404 error when journal not found") + } - // Test return - response.Reset() - request, _ = http.NewRequest("GET", "/slug", strings.NewReader("")) - db.Rows = &database.MockJournal_SingleRow{} - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if !strings.Contains(response.Content, "Title") { - t.Error("Expected content to be returned") - } + // Test return + response.Reset() + request, _ = http.NewRequest("GET", "/slug", strings.NewReader("")) + db.Rows = &database.MockJournal_SingleRow{} + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if !strings.Contains(response.Content, "Title") { + t.Error("Expected content to be returned") + } } diff --git a/internal/app/controller/apiv1/stats.go b/internal/app/controller/apiv1/stats.go index b67fb21..ba70c9d 100644 --- a/internal/app/controller/apiv1/stats.go +++ b/internal/app/controller/apiv1/stats.go @@ -1,75 +1,75 @@ package apiv1 import ( - "encoding/json" - "net/http" + "encoding/json" + "net/http" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" ) // Stats Provide statistics about the journal system type Stats struct { - controller.Super + controller.Super } type statsJSON struct { - Posts statsPostsJSON `json:"posts"` - Configuration statsConfigJSON `json:"configuration"` - Visits statsVisitsJSON `json:"visits"` + Posts statsPostsJSON `json:"posts"` + Configuration statsConfigJSON `json:"configuration"` + Visits statsVisitsJSON `json:"visits"` } type statsVisitsJSON struct { - Daily []model.DailyVisit `json:"daily"` - Monthly []model.MonthlyVisit `json:"monthly"` + Daily []model.DailyVisit `json:"daily"` + Monthly []model.MonthlyVisit `json:"monthly"` } type statsPostsJSON struct { - Count int `json:"count"` - FirstPostDate string `json:"first_post_date,omitempty"` + Count int `json:"count"` + FirstPostDate string `json:"first_post_date,omitempty"` } type statsConfigJSON struct { - Title string `json:"title"` - Description string `json:"description"` - Theme string `json:"theme"` - PostsPerPage int `json:"posts_per_page"` - GoogleAnalytics bool `json:"google_analytics"` - CreateEnabled bool `json:"create_enabled"` - EditEnabled bool `json:"edit_enabled"` + Title string `json:"title"` + Description string `json:"description"` + Theme string `json:"theme"` + PostsPerPage int `json:"posts_per_page"` + GoogleAnalytics bool `json:"google_analytics"` + CreateEnabled bool `json:"create_enabled"` + EditEnabled bool `json:"edit_enabled"` } // Run Stats action func (c *Stats) Run(response http.ResponseWriter, request *http.Request) { - stats := statsJSON{} + stats := statsJSON{} - container := c.Super.Container().(*app.Container) + container := c.Super.Container().(*app.Container) - js := model.Journals{Container: container} - allJournals := js.FetchAll() - stats.Posts.Count = len(allJournals) + js := model.Journals{Container: container} + allJournals := js.FetchAll() + stats.Posts.Count = len(allJournals) - if stats.Posts.Count > 0 { - firstPost := allJournals[stats.Posts.Count-1] - stats.Posts.FirstPostDate = firstPost.GetEditableDate() - } + if stats.Posts.Count > 0 { + firstPost := allJournals[stats.Posts.Count-1] + stats.Posts.FirstPostDate = firstPost.GetEditableDate() + } - stats.Configuration.Title = container.Configuration.Title - stats.Configuration.Description = container.Configuration.Description - stats.Configuration.Theme = container.Configuration.Theme - stats.Configuration.PostsPerPage = container.Configuration.PostsPerPage - stats.Configuration.GoogleAnalytics = container.Configuration.GoogleAnalyticsCode != "" - stats.Configuration.CreateEnabled = container.Configuration.EnableCreate - stats.Configuration.EditEnabled = container.Configuration.EnableEdit + stats.Configuration.Title = container.Configuration.Title + stats.Configuration.Description = container.Configuration.Description + stats.Configuration.Theme = container.Configuration.Theme + stats.Configuration.PostsPerPage = container.Configuration.PostsPerPage + stats.Configuration.GoogleAnalytics = container.Configuration.GoogleAnalyticsCode != "" + stats.Configuration.CreateEnabled = container.Configuration.EnableCreate + stats.Configuration.EditEnabled = container.Configuration.EnableEdit - vs := model.Visits{Container: container} - stats.Visits.Daily = vs.GetDailyStats(14) - stats.Visits.Monthly = vs.GetMonthlyStats() + vs := model.Visits{Container: container} + stats.Visits.Daily = vs.GetDailyStats(14) + stats.Visits.Monthly = vs.GetMonthlyStats() - // Send JSON response - response.Header().Add("Content-Type", "application/json") - encoder := json.NewEncoder(response) - encoder.SetEscapeHTML(false) - encoder.Encode(stats) + // Send JSON response + response.Header().Add("Content-Type", "application/json") + encoder := json.NewEncoder(response) + encoder.SetEscapeHTML(false) + encoder.Encode(stats) } diff --git a/internal/app/controller/apiv1/stats_test.go b/internal/app/controller/apiv1/stats_test.go index 7f5c583..c352eb8 100644 --- a/internal/app/controller/apiv1/stats_test.go +++ b/internal/app/controller/apiv1/stats_test.go @@ -1,58 +1,58 @@ package apiv1 import ( - "net/http" - "strings" - "testing" + "net/http" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestStats_Run(t *testing.T) { - db := &database.MockSqlite{} - configuration := app.DefaultConfiguration() - configuration.PostsPerPage = 25 // Custom setting - configuration.GoogleAnalyticsCode = "UA-123456" // Custom GA code - container := &app.Container{Configuration: configuration, Db: db} - response := &controller.MockResponse{} - response.Reset() - controller := &Stats{} - controller.DisableTracking() - - // Test with journals - db.Rows = &database.MockJournal_MultipleRows{} - request := &http.Request{Method: "GET"} - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - - if response.StatusCode != 200 { - t.Error("Expected 200 status code") - } - if response.Headers.Get("Content-Type") != "application/json" { - t.Error("Expected JSON content type") - } - - if !strings.Contains(response.Content, "count\":2,") { - t.Errorf("Expected post count to be 2, got response %s", response.Content) - } - if !strings.Contains(response.Content, "posts_per_page\":25,") { - t.Errorf("Expected posts per page to be 25, got response %s", response.Content) - } - if !strings.Contains(response.Content, "google_analytics\":true") { - t.Error("Expected Google Analytics to be enabled") - } - - // Now test with no journals - response.Reset() - db.Rows = &database.MockRowsEmpty{} - controller.Run(response, request) - - if !strings.Contains(response.Content, "count\":0}") { - t.Errorf("Expected post count to be 0, got response %s", response.Content) - } - if strings.Contains(response.Content, "first_post_date") { - t.Error("Expected first_post_date to be omitted when no posts exist") - } + db := &database.MockSqlite{} + configuration := app.DefaultConfiguration() + configuration.PostsPerPage = 25 // Custom setting + configuration.GoogleAnalyticsCode = "UA-123456" // Custom GA code + container := &app.Container{Configuration: configuration, Db: db} + response := &controller.MockResponse{} + response.Reset() + controller := &Stats{} + controller.DisableTracking() + + // Test with journals + db.Rows = &database.MockJournal_MultipleRows{} + request := &http.Request{Method: "GET"} + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + + if response.StatusCode != 200 { + t.Error("Expected 200 status code") + } + if response.Headers.Get("Content-Type") != "application/json" { + t.Error("Expected JSON content type") + } + + if !strings.Contains(response.Content, "count\":2,") { + t.Errorf("Expected post count to be 2, got response %s", response.Content) + } + if !strings.Contains(response.Content, "posts_per_page\":25,") { + t.Errorf("Expected posts per page to be 25, got response %s", response.Content) + } + if !strings.Contains(response.Content, "google_analytics\":true") { + t.Error("Expected Google Analytics to be enabled") + } + + // Now test with no journals + response.Reset() + db.Rows = &database.MockRowsEmpty{} + controller.Run(response, request) + + if !strings.Contains(response.Content, "count\":0}") { + t.Errorf("Expected post count to be 0, got response %s", response.Content) + } + if strings.Contains(response.Content, "first_post_date") { + t.Error("Expected first_post_date to be omitted when no posts exist") + } } diff --git a/internal/app/controller/apiv1/update.go b/internal/app/controller/apiv1/update.go index 26f6bd3..36b9fa8 100644 --- a/internal/app/controller/apiv1/update.go +++ b/internal/app/controller/apiv1/update.go @@ -1,58 +1,58 @@ package apiv1 import ( - "encoding/json" - "net/http" + "encoding/json" + "net/http" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" ) // Update Update an existing entry via API type Update struct { - controller.Super + controller.Super } // Run Update action func (c *Update) Run(response http.ResponseWriter, request *http.Request) { - container := c.Super.Container().(*app.Container) - if !container.Configuration.EnableEdit { - response.WriteHeader(http.StatusForbidden) - return - } + container := c.Super.Container().(*app.Container) + if !container.Configuration.EnableEdit { + response.WriteHeader(http.StatusForbidden) + return + } - js := model.Journals{Container: container} - journal := js.FindBySlug(c.Params()[1]) + js := model.Journals{Container: container} + journal := js.FindBySlug(c.Params()[1]) - response.Header().Add("Content-Type", "application/json") - if journal.ID == 0 { - response.WriteHeader(http.StatusNotFound) - } else { - var journalRequest = journalFromJSON{} - decoder := json.NewDecoder(request.Body) - err := decoder.Decode(&journalRequest) - if err != nil { - response.WriteHeader(http.StatusBadRequest) - } else { - // Update only fields that are present - if journalRequest.Title != "" { - journal.Title = journalRequest.Title - } - if journalRequest.Date != "" { - journal.Date = journalRequest.Date - } - if journalRequest.Content != "" { - journal.Content = journalRequest.Content - } - if !model.Validate(journal.Title, journal.Date, journal.Content) { - response.WriteHeader(http.StatusBadRequest) - } else { - journal = js.Save(journal) - encoder := json.NewEncoder(response) - encoder.SetEscapeHTML(false) - encoder.Encode(MapJournalToJSON(journal)) - } - } - } + response.Header().Add("Content-Type", "application/json") + if journal.ID == 0 { + response.WriteHeader(http.StatusNotFound) + } else { + var journalRequest = journalFromJSON{} + decoder := json.NewDecoder(request.Body) + err := decoder.Decode(&journalRequest) + if err != nil { + response.WriteHeader(http.StatusBadRequest) + } else { + // Update only fields that are present + if journalRequest.Title != "" { + journal.Title = journalRequest.Title + } + if journalRequest.Date != "" { + journal.Date = journalRequest.Date + } + if journalRequest.Content != "" { + journal.Content = journalRequest.Content + } + if !model.Validate(journal.Title, journal.Date, journal.Content) { + response.WriteHeader(http.StatusBadRequest) + } else { + journal = js.Save(journal) + encoder := json.NewEncoder(response) + encoder.SetEscapeHTML(false) + encoder.Encode(MapJournalToJSON(journal)) + } + } + } } diff --git a/internal/app/controller/apiv1/update_test.go b/internal/app/controller/apiv1/update_test.go index 0f6446f..ac0fd7b 100644 --- a/internal/app/controller/apiv1/update_test.go +++ b/internal/app/controller/apiv1/update_test.go @@ -1,68 +1,68 @@ package apiv1 import ( - "net/http" - "strings" - "testing" + "net/http" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestUpdate_Run(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Configuration: app.DefaultConfiguration(), Db: db} - response := &controller.MockResponse{} - response.Reset() - controller := &Update{} - controller.DisableTracking() + db := &database.MockSqlite{} + container := &app.Container{Configuration: app.DefaultConfiguration(), Db: db} + response := &controller.MockResponse{} + response.Reset() + controller := &Update{} + controller.DisableTracking() - // Test forbidden - container.Configuration.EnableEdit = false - request, _ := http.NewRequest("POST", "/slug/edit", strings.NewReader("{\"not\":\"valid\":\"json\"}")) - request.Header.Add("Content-Type", "application/json") - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if response.StatusCode != 403 { - t.Error("Expected 403 error when creation is disabled") - } + // Test forbidden + container.Configuration.EnableEdit = false + request, _ := http.NewRequest("POST", "/slug/edit", strings.NewReader("{\"not\":\"valid\":\"json\"}")) + request.Header.Add("Content-Type", "application/json") + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if response.StatusCode != 403 { + t.Error("Expected 403 error when creation is disabled") + } - // Test not found/error with GET/POST - container.Configuration.EnableEdit = true - db.Rows = &database.MockRowsEmpty{} - request = &http.Request{Method: "POST"} - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if response.StatusCode != 404 { - t.Error("Expected 404 error when journal not found") - } + // Test not found/error with GET/POST + container.Configuration.EnableEdit = true + db.Rows = &database.MockRowsEmpty{} + request = &http.Request{Method: "POST"} + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if response.StatusCode != 404 { + t.Error("Expected 404 error when journal not found") + } - // Test for bad request on invalid JSON - response.Reset() - request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("{\"not\":\"valid\":\"json\"}")) - request.Header.Add("Content-Type", "application/json") - db.Rows = &database.MockJournal_SingleRow{} - controller.Run(response, request) - if response.StatusCode != 400 { - t.Error("Expected 400 error when invalid JSON provided") - } + // Test for bad request on invalid JSON + response.Reset() + request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("{\"not\":\"valid\":\"json\"}")) + request.Header.Add("Content-Type", "application/json") + db.Rows = &database.MockJournal_SingleRow{} + controller.Run(response, request) + if response.StatusCode != 400 { + t.Error("Expected 400 error when invalid JSON provided") + } - // Test Journal is retrieved on save - response.Reset() - request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("{\"title\":\"Something New\",\"date\":\"2018-01-01\",\"content\":\"New\"}")) - request.Header.Add("Content-Type", "application/json") - db.Rows = &database.MockJournal_SingleRow{} - controller.Run(response, request) - if response.StatusCode != 200 || !strings.Contains(response.Content, "Something New") { - t.Error("Expected new title to be within content") - } + // Test Journal is retrieved on save + response.Reset() + request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("{\"title\":\"Something New\",\"date\":\"2018-01-01\",\"content\":\"New\"}")) + request.Header.Add("Content-Type", "application/json") + db.Rows = &database.MockJournal_SingleRow{} + controller.Run(response, request) + 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") - } + // 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/badrequest.go b/internal/app/controller/web/badrequest.go index 00334b6..590985b 100644 --- a/internal/app/controller/web/badrequest.go +++ b/internal/app/controller/web/badrequest.go @@ -1,39 +1,39 @@ package web import ( - "net/http" - "text/template" + "net/http" + "text/template" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/pkg/controller" ) // BadRequest Display a 404 not found page type BadRequest struct { - controller.Super + controller.Super } type badRequestTemplateData struct { - Container interface{} + Container interface{} } // Run BadRequest func (c *BadRequest) Run(response http.ResponseWriter, request *http.Request) { - data := badRequestTemplateData{} - data.Container = c.Super.Container().(*app.Container) + data := badRequestTemplateData{} + data.Container = c.Super.Container().(*app.Container) - response.WriteHeader(http.StatusNotFound) + response.WriteHeader(http.StatusNotFound) - c.SaveSession(response) - template, _ := template.ParseFiles( - "./web/templates/_layout/default.html.tmpl", - "./web/templates/error.html.tmpl") - template.ExecuteTemplate(response, "layout", data) + c.SaveSession(response) + template, _ := template.ParseFiles( + "./web/templates/_layout/default.html.tmpl", + "./web/templates/error.html.tmpl") + template.ExecuteTemplate(response, "layout", data) } // RunBadRequest calls the bad request from an existing controller func RunBadRequest(response http.ResponseWriter, request *http.Request, container interface{}) { - errorController := BadRequest{} - errorController.Init(container, []string{}, request) - errorController.Run(response, request) + errorController := BadRequest{} + errorController.Init(container, []string{}, request) + errorController.Run(response, request) } diff --git a/internal/app/controller/web/badrequest_test.go b/internal/app/controller/web/badrequest_test.go index f71b3ea..5a37f0a 100644 --- a/internal/app/controller/web/badrequest_test.go +++ b/internal/app/controller/web/badrequest_test.go @@ -1,42 +1,42 @@ package web import ( - "net/http" - "os" - "path" - "runtime" - "strings" - "testing" + "net/http" + "os" + "path" + "runtime" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" ) func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "../../../..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } } func TestError_Run(t *testing.T) { - response := controller.NewMockResponse() - configuration := app.DefaultConfiguration() - container := &app.Container{Configuration: configuration} - controller := &BadRequest{} - controller.DisableTracking() - request, _ := http.NewRequest("GET", "/", strings.NewReader("")) + response := controller.NewMockResponse() + configuration := app.DefaultConfiguration() + container := &app.Container{Configuration: configuration} + controller := &BadRequest{} + controller.DisableTracking() + request, _ := http.NewRequest("GET", "/", strings.NewReader("")) - // Test header and response - controller.Init(container, []string{}, request) - controller.Run(response, request) - if response.StatusCode != 404 || !strings.Contains(response.Content, "Page Not Found") { - t.Error("Expected 404 error when journal not found") - } - if !strings.Contains(response.Content, "Page Not Found - A Fantastic Journal") { - t.Error("Expected HTML title to be in place") - } + // Test header and response + controller.Init(container, []string{}, request) + controller.Run(response, request) + if response.StatusCode != 404 || !strings.Contains(response.Content, "Page Not Found") { + t.Error("Expected 404 error when journal not found") + } + if !strings.Contains(response.Content, "Page Not Found - A Fantastic Journal") { + t.Error("Expected HTML title to be in place") + } } diff --git a/internal/app/controller/web/calendar.go b/internal/app/controller/web/calendar.go index cc2f798..3dda8e7 100644 --- a/internal/app/controller/web/calendar.go +++ b/internal/app/controller/web/calendar.go @@ -1,138 +1,138 @@ package web import ( - "log" - "net/http" - "strconv" - "strings" - "text/template" - "time" - - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" - "golang.org/x/text/cases" - "golang.org/x/text/language" + "log" + "net/http" + "strconv" + "strings" + "text/template" + "time" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) // Calendar Handle displaying a calendar with blog entries for given days type Calendar struct { - controller.Super + controller.Super } type day struct { - Date time.Time - IsEmpty bool + Date time.Time + IsEmpty bool } type calendarTemplateData struct { - Container interface{} - Days map[int][]model.Journal - Weeks [][]day - CurrentDate time.Time - PrevYear int - PrevYearUrl string - NextYear int - NextYearUrl string - PrevMonth string - PrevMonthUrl string - NextMonth string - NextMonthUrl string + Container interface{} + Days map[int][]model.Journal + Weeks [][]day + CurrentDate time.Time + PrevYear int + PrevYearUrl string + NextYear int + NextYearUrl string + PrevMonth string + PrevMonthUrl string + NextMonth string + NextMonthUrl string } // Run Calendar action func (c *Calendar) Run(response http.ResponseWriter, request *http.Request) { - data := calendarTemplateData{} - - container := c.Super.Container().(*app.Container) - data.Container = container - js := model.Journals{Container: container} - - // Load date from parameters if available (either 2006/jan or 2006) - date := time.Now() - var err error - if len(c.Params()) == 3 { - date, err = time.Parse("2006 Jan 02", c.Params()[1]+" "+cases.Title(language.English, cases.NoLower).String(c.Params()[2])+" 25") - } else if len(c.Params()) == 2 { - date, err = time.Parse("2006-01-02", c.Params()[1]+"-01-01") - } - if err != nil { - log.Print(err) - RunBadRequest(response, request, container) - return - } - - firstOfMonth := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location()) - startWeekday := int(firstOfMonth.Weekday()) - - // Find number of days in month - nextMonth := firstOfMonth.AddDate(0, 1, 0) - lastOfMonth := nextMonth.AddDate(0, 0, -1) - daysInMonth := lastOfMonth.Day() - - data.Days = map[int][]model.Journal{} - data.Weeks = [][]day{} - week := []day{} - - // Fill in blanks before first day - for range startWeekday { - week = append(week, day{IsEmpty: true}) - } - - // Fill in actual days - for d := 1; d <= daysInMonth; d++ { - thisDate := time.Date(date.Year(), date.Month(), d, 0, 0, 0, 0, date.Location()) - data.Days[d] = js.FetchByDate(thisDate.Format("2006-01-02")) - week = append(week, day{ - Date: thisDate, - IsEmpty: false, - }) - - // If Saturday, start a new week - if thisDate.Weekday() == time.Saturday { - data.Weeks = append(data.Weeks, week) - week = []day{} - } - } - - // Fill in blanks after last day - if len(week) > 0 { - for len(week) < 7 { - week = append(week, day{IsEmpty: true}) - } - data.Weeks = append(data.Weeks, week) - } - - // Load prev/next year and month - firstEntry := js.FindNext(0) - firstEntryDate, _ := time.Parse("2006-01-02", firstEntry.GetEditableDate()) - if date.Year() < time.Now().Year() { - data.NextYear = date.Year() + 1 - data.NextYearUrl = strconv.Itoa(data.NextYear) + "/" + strings.ToLower(date.Format("Jan")) - if date.AddDate(1, 0, 0).After(time.Now()) { - data.NextYearUrl = strconv.Itoa(data.NextYear) + "/" + strings.ToLower(time.Now().Format("Jan")) - } - } - if date.Year() > firstEntryDate.Year() { - data.PrevYear = date.Year() - 1 - data.PrevYearUrl = strconv.Itoa(data.PrevYear) + "/" + strings.ToLower(date.Format("Jan")) - if date.AddDate(-1, 0, 0).Before(firstEntryDate) { - data.PrevYearUrl = strconv.Itoa(data.PrevYear) + "/" + strings.ToLower(firstEntryDate.Format("Jan")) - } - } - if date.Year() < time.Now().Year() || date.Month() < time.Now().Month() { - data.NextMonth = date.AddDate(0, 0, 31).Format("January") - data.NextMonthUrl = strings.ToLower(date.AddDate(0, 0, 31).Format("2006/Jan")) - } - if date.Year() > firstEntryDate.Year() || date.Month() > firstEntryDate.Month() { - data.PrevMonth = date.AddDate(0, 0, -31).Format("January") - data.PrevMonthUrl = strings.ToLower(date.AddDate(0, 0, -31).Format("2006/Jan")) - } - data.CurrentDate = date - - template, _ := template.ParseFiles( - "./web/templates/_layout/default.html.tmpl", - "./web/templates/calendar.html.tmpl") - template.ExecuteTemplate(response, "layout", data) + data := calendarTemplateData{} + + container := c.Super.Container().(*app.Container) + data.Container = container + js := model.Journals{Container: container} + + // Load date from parameters if available (either 2006/jan or 2006) + date := time.Now() + var err error + if len(c.Params()) == 3 { + date, err = time.Parse("2006 Jan 02", c.Params()[1]+" "+cases.Title(language.English, cases.NoLower).String(c.Params()[2])+" 25") + } else if len(c.Params()) == 2 { + date, err = time.Parse("2006-01-02", c.Params()[1]+"-01-01") + } + if err != nil { + log.Print(err) + RunBadRequest(response, request, container) + return + } + + firstOfMonth := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location()) + startWeekday := int(firstOfMonth.Weekday()) + + // Find number of days in month + nextMonth := firstOfMonth.AddDate(0, 1, 0) + lastOfMonth := nextMonth.AddDate(0, 0, -1) + daysInMonth := lastOfMonth.Day() + + data.Days = map[int][]model.Journal{} + data.Weeks = [][]day{} + week := []day{} + + // Fill in blanks before first day + for range startWeekday { + week = append(week, day{IsEmpty: true}) + } + + // Fill in actual days + for d := 1; d <= daysInMonth; d++ { + thisDate := time.Date(date.Year(), date.Month(), d, 0, 0, 0, 0, date.Location()) + data.Days[d] = js.FetchByDate(thisDate.Format("2006-01-02")) + week = append(week, day{ + Date: thisDate, + IsEmpty: false, + }) + + // If Saturday, start a new week + if thisDate.Weekday() == time.Saturday { + data.Weeks = append(data.Weeks, week) + week = []day{} + } + } + + // Fill in blanks after last day + if len(week) > 0 { + for len(week) < 7 { + week = append(week, day{IsEmpty: true}) + } + data.Weeks = append(data.Weeks, week) + } + + // Load prev/next year and month + firstEntry := js.FindNext(0) + firstEntryDate, _ := time.Parse("2006-01-02", firstEntry.GetEditableDate()) + if date.Year() < time.Now().Year() { + data.NextYear = date.Year() + 1 + data.NextYearUrl = strconv.Itoa(data.NextYear) + "/" + strings.ToLower(date.Format("Jan")) + if date.AddDate(1, 0, 0).After(time.Now()) { + data.NextYearUrl = strconv.Itoa(data.NextYear) + "/" + strings.ToLower(time.Now().Format("Jan")) + } + } + if date.Year() > firstEntryDate.Year() { + data.PrevYear = date.Year() - 1 + data.PrevYearUrl = strconv.Itoa(data.PrevYear) + "/" + strings.ToLower(date.Format("Jan")) + if date.AddDate(-1, 0, 0).Before(firstEntryDate) { + data.PrevYearUrl = strconv.Itoa(data.PrevYear) + "/" + strings.ToLower(firstEntryDate.Format("Jan")) + } + } + if date.Year() < time.Now().Year() || date.Month() < time.Now().Month() { + data.NextMonth = date.AddDate(0, 0, 31).Format("January") + data.NextMonthUrl = strings.ToLower(date.AddDate(0, 0, 31).Format("2006/Jan")) + } + if date.Year() > firstEntryDate.Year() || date.Month() > firstEntryDate.Month() { + data.PrevMonth = date.AddDate(0, 0, -31).Format("January") + data.PrevMonthUrl = strings.ToLower(date.AddDate(0, 0, -31).Format("2006/Jan")) + } + data.CurrentDate = date + + template, _ := template.ParseFiles( + "./web/templates/_layout/default.html.tmpl", + "./web/templates/calendar.html.tmpl") + template.ExecuteTemplate(response, "layout", data) } diff --git a/internal/app/controller/web/calendar_test.go b/internal/app/controller/web/calendar_test.go index 032fb75..7d9e036 100644 --- a/internal/app/controller/web/calendar_test.go +++ b/internal/app/controller/web/calendar_test.go @@ -1,154 +1,154 @@ package web import ( - "net/http" - "os" - "path" - "runtime" - "strconv" - "strings" - "testing" - "time" + "net/http" + "os" + "path" + "runtime" + "strconv" + "strings" + "testing" + "time" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "../../../..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } } func TestCalendarRun(t *testing.T) { - db := &database.MockSqlite{} - configuration := app.DefaultConfiguration() - container := &app.Container{Configuration: configuration, Db: db} - response := controller.NewMockResponse() - controller := &Calendar{} - controller.DisableTracking() + db := &database.MockSqlite{} + configuration := app.DefaultConfiguration() + container := &app.Container{Configuration: configuration, Db: db} + response := controller.NewMockResponse() + controller := &Calendar{} + controller.DisableTracking() - // Test showing current year/month (only prev nav) - today := time.Now() - firstOfMonth := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, today.Location()) - daysInMonth := firstOfMonth.AddDate(0, 1, -1).Day() - db.EnableMultiMode() - db.AppendResult(&database.MockJournal_SingleRow{}) - for d := 2; d <= daysInMonth; d++ { - db.AppendResult(&database.MockRowsEmpty{}) - } - db.AppendResult(&database.MockJournal_SingleRow{}) - request, _ := http.NewRequest("GET", "/calendar", strings.NewReader("")) - controller.Init(container, []string{}, request) - controller.Run(response, request) - if !strings.Contains(response.Content, "Title") { - t.Error("Expected title of journal to be shown in calendar") - } - if !strings.Contains(response.Content, "class=\"prev prev-year\"") { - t.Error("Expected previous year link to be shown") - } - if !strings.Contains(response.Content, "class=\"prev prev-month\"") { - t.Error("Expected previous month link to be shown") - } - if strings.Contains(response.Content, "class=\"next next-year\"") { - t.Error("Expected next year link to be missing") - } - if strings.Contains(response.Content, "class=\"next next-month\"") { - t.Error("Expected next month link to be missing") - } + // Test showing current year/month (only prev nav) + today := time.Now() + firstOfMonth := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, today.Location()) + daysInMonth := firstOfMonth.AddDate(0, 1, -1).Day() + db.EnableMultiMode() + db.AppendResult(&database.MockJournal_SingleRow{}) + for d := 2; d <= daysInMonth; d++ { + db.AppendResult(&database.MockRowsEmpty{}) + } + db.AppendResult(&database.MockJournal_SingleRow{}) + request, _ := http.NewRequest("GET", "/calendar", strings.NewReader("")) + controller.Init(container, []string{}, request) + controller.Run(response, request) + if !strings.Contains(response.Content, "Title") { + t.Error("Expected title of journal to be shown in calendar") + } + if !strings.Contains(response.Content, "class=\"prev prev-year\"") { + t.Error("Expected previous year link to be shown") + } + if !strings.Contains(response.Content, "class=\"prev prev-month\"") { + t.Error("Expected previous month link to be shown") + } + if strings.Contains(response.Content, "class=\"next next-year\"") { + t.Error("Expected next year link to be missing") + } + if strings.Contains(response.Content, "class=\"next next-month\"") { + t.Error("Expected next month link to be missing") + } - // Test showing beginning (only next nav) - response.Reset() - db.EnableMultiMode() - db.AppendResult(&database.MockJournal_SingleRow{}) - for d := 2; d <= 28; d++ { - db.AppendResult(&database.MockRowsEmpty{}) - } - db.AppendResult(&database.MockJournal_SingleRow{}) - request, _ = http.NewRequest("GET", "/calendar/2018/feb", strings.NewReader("")) - controller.Init(container, []string{"", "2018", "feb"}, request) - controller.Run(response, request) - if !strings.Contains(response.Content, "Title") { - t.Error("Expected title of journal to be shown in calendar") - } - if !strings.Contains(response.Content, "

2018

") || !strings.Contains(response.Content, "

February2018

") || !strings.Contains(response.Content, "

February2019

") || !strings.Contains(response.Content, "

January2019

") || !strings.Contains(response.Content, "

JanuaryEdit Title - A Fantastic Journal") { - t.Error("Expected HTML title to be in place") - } + // Display no error + response.Reset() + request, _ = http.NewRequest("GET", "/slug/edit", strings.NewReader("")) + db.Rows = &database.MockJournal_SingleRow{} + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if strings.Contains(response.Content, "div class=\"error\"") { + t.Error("Expected no error to be shown in form") + } + if !strings.Contains(response.Content, "Edit Title - A Fantastic Journal") { + t.Error("Expected HTML title to be in place") + } - // Redirect if empty content on POST - response.Reset() - request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("title=&date=&content=")) - request.Header.Add("Content-Type", "application/x-www-form-urlencoded") - db.Rows = &database.MockJournal_SingleRow{} - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if response.StatusCode != 302 || response.Headers.Get("Location") != "/slug/edit" { - t.Error("Expected redirect back to same page") - } + // Redirect if empty content on POST + response.Reset() + request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("title=&date=&content=")) + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + db.Rows = &database.MockJournal_SingleRow{} + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if response.StatusCode != 302 || response.Headers.Get("Location") != "/slug/edit" { + t.Error("Expected redirect back to same page") + } - // Validate error cookie on redirect - // We need to create a new controller with the cookie to test flash values - newController := &Edit{} - newController.DisableTracking() - request, _ = http.NewRequest("GET", "/", strings.NewReader("")) - request.Header.Add("Cookie", response.Headers.Get("Set-Cookie")) - newController.Init(container, []string{"", "0"}, request) - // Skip GetFlash since we only care that an error flash was added - // We can verify the redirect is correct + // Validate error cookie on redirect + // We need to create a new controller with the cookie to test flash values + newController := &Edit{} + newController.DisableTracking() + request, _ = http.NewRequest("GET", "/", strings.NewReader("")) + request.Header.Add("Cookie", response.Headers.Get("Set-Cookie")) + newController.Init(container, []string{"", "0"}, request) + // Skip GetFlash since we only care that an error flash was added + // We can verify the redirect is correct - // Test form data preservation when validation fails - response.Reset() - // Create a new controller instance for this test - prevController := &Edit{} - prevController.DisableTracking() - // Submit a form with a missing field (date is empty) - request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("title=Updated+Title&date=&content=Updated+Content")) - request.Header.Add("Content-Type", "application/x-www-form-urlencoded") - db.Rows = &database.MockJournal_SingleRow{} - prevController.Init(container, []string{"", "slug"}, request) + // Test form data preservation when validation fails + response.Reset() + // Create a new controller instance for this test + prevController := &Edit{} + prevController.DisableTracking() + // Submit a form with a missing field (date is empty) + request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("title=Updated+Title&date=&content=Updated+Content")) + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + db.Rows = &database.MockJournal_SingleRow{} + prevController.Init(container, []string{"", "slug"}, request) - // Verify form data is saved in session - prevController.Run(response, request) - if response.StatusCode != 302 || response.Headers.Get("Location") != "/slug/edit" { - t.Error("Expected redirect back to edit page") - } + // Verify form data is saved in session + prevController.Run(response, request) + if response.StatusCode != 302 || response.Headers.Get("Location") != "/slug/edit" { + t.Error("Expected redirect back to edit page") + } - // Check if form_data was set in the session - formData := prevController.Session().Get("form_data") - if formData == nil { - t.Error("Expected form_data to be set in session") - } else { - // Cast and verify form data values - formMap := formData.(map[string]string) - if formMap["title"] != "Updated Title" { - t.Errorf("Expected title to be 'Updated Title', got '%s'", formMap["title"]) - } - if formMap["content"] != "Updated Content" { - t.Errorf("Expected content to be 'Updated Content', got '%s'", formMap["content"]) - } - if formMap["date"] != "" { - t.Errorf("Expected date to be empty, got '%s'", formMap["date"]) - } - } + // Check if form_data was set in the session + formData := prevController.Session().Get("form_data") + if formData == nil { + t.Error("Expected form_data to be set in session") + } else { + // Cast and verify form data values + formMap := formData.(map[string]string) + if formMap["title"] != "Updated Title" { + t.Errorf("Expected title to be 'Updated Title', got '%s'", formMap["title"]) + } + if formMap["content"] != "Updated Content" { + t.Errorf("Expected content to be 'Updated Content', got '%s'", formMap["content"]) + } + if formMap["date"] != "" { + t.Errorf("Expected date to be empty, got '%s'", formMap["date"]) + } + } - // Redirect on success - response.Reset() - request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("title=Title&date=2018-02-01&content=Test+again")) - request.Header.Add("Content-Type", "application/x-www-form-urlencoded") - db.Rows = &database.MockJournal_SingleRow{} - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if response.StatusCode != 302 || response.Headers.Get("Location") != "/" { - t.Error("Expected redirect back to home with saved banner shown") - } + // Redirect on success + response.Reset() + request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("title=Title&date=2018-02-01&content=Test+again")) + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + db.Rows = &database.MockJournal_SingleRow{} + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if response.StatusCode != 302 || response.Headers.Get("Location") != "/" { + t.Error("Expected redirect back to home with saved banner shown") + } - // Validate saved cookie on redirect - // We need to create a new controller with the cookie to test flash values - saveController := &Edit{} - request, _ = http.NewRequest("GET", "/", strings.NewReader("")) - request.Header.Add("Cookie", response.Headers.Get("Set-Cookie")) - saveController.Init(container, []string{"", "0"}, request) - // Skip GetFlash since we only care that a saved flash was added - // We can verify the redirect is correct + // Validate saved cookie on redirect + // We need to create a new controller with the cookie to test flash values + saveController := &Edit{} + request, _ = http.NewRequest("GET", "/", strings.NewReader("")) + request.Header.Add("Cookie", response.Headers.Get("Set-Cookie")) + saveController.Init(container, []string{"", "0"}, request) + // Skip GetFlash since we only care that a saved flash was added + // We can verify the redirect is correct } diff --git a/internal/app/controller/web/helpers.go b/internal/app/controller/web/helpers.go index 97c9214..43dbf13 100644 --- a/internal/app/controller/web/helpers.go +++ b/internal/app/controller/web/helpers.go @@ -1,40 +1,40 @@ package web import ( - "net/http" - "text/template" + "net/http" + "text/template" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" ) type formTemplateData struct { - Container interface{} - Error bool - Journal model.Journal + Container interface{} + Error bool + Journal model.Journal } func RenderFromSession(c controller.Controller, data formTemplateData, templateFile string, response http.ResponseWriter) { - data.Error = false - flash := c.Session().GetFlash() - if flash != nil && flash[0] == "error" { - data.Error = true + data.Error = false + flash := c.Session().GetFlash() + if flash != nil && flash[0] == "error" { + data.Error = true - formData := c.Session().Get("form_data") - if formData != nil { - formMap := formData.(map[string]string) - data.Journal.Title = formMap["title"] - data.Journal.Date = formMap["date"] - data.Journal.Content = formMap["content"] + formData := c.Session().Get("form_data") + if formData != nil { + formMap := formData.(map[string]string) + data.Journal.Title = formMap["title"] + data.Journal.Date = formMap["date"] + data.Journal.Content = formMap["content"] - c.Session().Delete("form_data") - } - } + c.Session().Delete("form_data") + } + } - c.SaveSession(response) - responseTemplate, _ := template.ParseFiles( - "./web/templates/_layout/default.html.tmpl", - "./web/templates/"+templateFile+".html.tmpl", - "./web/templates/_partial/form.html.tmpl") - responseTemplate.ExecuteTemplate(response, "layout", data) + c.SaveSession(response) + responseTemplate, _ := template.ParseFiles( + "./web/templates/_layout/default.html.tmpl", + "./web/templates/"+templateFile+".html.tmpl", + "./web/templates/_partial/form.html.tmpl") + responseTemplate.ExecuteTemplate(response, "layout", data) } diff --git a/internal/app/controller/web/index.go b/internal/app/controller/web/index.go index 052c967..194ed95 100644 --- a/internal/app/controller/web/index.go +++ b/internal/app/controller/web/index.go @@ -1,62 +1,62 @@ package web import ( - "net/http" - "text/template" - - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/controller/apiv1" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" - "github.com/jamiefdhurst/journal/pkg/database" + "net/http" + "text/template" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/controller/apiv1" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/pkg/database" ) // Index Handle displaying all blog entries type Index struct { - controller.Super + controller.Super } type indexTemplateData struct { - Container interface{} - Excerpt func(model.Journal) string - Journals []model.Journal - Pages []int - Pagination database.PaginationDisplay - Saved bool + Container interface{} + Excerpt func(model.Journal) string + Journals []model.Journal + Pages []int + Pagination database.PaginationDisplay + Saved bool } // Run Index action func (c *Index) Run(response http.ResponseWriter, request *http.Request) { - data := indexTemplateData{} - - container := c.Super.Container().(*app.Container) - data.Container = container - js := model.Journals{Container: container} - - var paginationInfo database.PaginationInformation - data.Journals, paginationInfo = apiv1.ListData(request, js) - data.Pagination = database.DisplayPagination(paginationInfo) - data.Saved = false - flash := c.Session().GetFlash() - if flash != nil && flash[0] == "saved" { - data.Saved = true - } - - data.Pages = make([]int, database.PAGINATION_MAX_PAGES) - i := 0 - for p := data.Pagination.FirstPage; p <= data.Pagination.LastPage; p++ { - data.Pages[i] = p - i++ - } - - data.Excerpt = func(j model.Journal) string { - return j.GetHTMLExcerpt(container.Configuration.ExcerptWords) - } - - c.SaveSession(response) - template, _ := template.ParseFiles( - "./web/templates/_layout/default.html.tmpl", - "./web/templates/index.html.tmpl") - template.ExecuteTemplate(response, "layout", data) + data := indexTemplateData{} + + container := c.Super.Container().(*app.Container) + data.Container = container + js := model.Journals{Container: container} + + var paginationInfo database.PaginationInformation + data.Journals, paginationInfo = apiv1.ListData(request, js) + data.Pagination = database.DisplayPagination(paginationInfo) + data.Saved = false + flash := c.Session().GetFlash() + if flash != nil && flash[0] == "saved" { + data.Saved = true + } + + data.Pages = make([]int, database.PAGINATION_MAX_PAGES) + i := 0 + for p := data.Pagination.FirstPage; p <= data.Pagination.LastPage; p++ { + data.Pages[i] = p + i++ + } + + data.Excerpt = func(j model.Journal) string { + return j.GetHTMLExcerpt(container.Configuration.ExcerptWords) + } + + c.SaveSession(response) + template, _ := template.ParseFiles( + "./web/templates/_layout/default.html.tmpl", + "./web/templates/index.html.tmpl") + template.ExecuteTemplate(response, "layout", data) } diff --git a/internal/app/controller/web/index_test.go b/internal/app/controller/web/index_test.go index 18a7a97..2bba249 100644 --- a/internal/app/controller/web/index_test.go +++ b/internal/app/controller/web/index_test.go @@ -1,87 +1,87 @@ package web import ( - "net/http" - "os" - "path" - "runtime" - "strings" - "testing" + "net/http" + "os" + "path" + "runtime" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "../../../..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } } func TestIndex_Run(t *testing.T) { - db := &database.MockSqlite{} - configuration := app.DefaultConfiguration() - configuration.PostsPerPage = 2 - configuration.SessionKey = "12345678901234567890123456789012" - container := &app.Container{Configuration: configuration, Db: db} - response := controller.NewMockResponse() - controller := &Index{} - controller.DisableTracking() + db := &database.MockSqlite{} + configuration := app.DefaultConfiguration() + configuration.PostsPerPage = 2 + configuration.SessionKey = "12345678901234567890123456789012" + container := &app.Container{Configuration: configuration, Db: db} + response := controller.NewMockResponse() + controller := &Index{} + controller.DisableTracking() - // Test showing all Journals - db.EnableMultiMode() - db.AppendResult(&database.MockPagination_Result{TotalResults: 2}) - db.AppendResult(&database.MockJournal_MultipleRows{}) - request, _ := http.NewRequest("GET", "/", strings.NewReader("")) - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if !strings.Contains(response.Content, "Title 2") { - t.Error("Expected all journals to be displayed on screen") - } - if !strings.Contains(response.Content, "A Fantastic Journal") { - t.Error("Expected default HTML title to be in place") - } - if !strings.Contains(response.Content, "A Fantastic Journal") { + t.Error("Expected default HTML title to be in place") + } + if !strings.Contains(response.Content, "Create New Post - A Fantastic Journal") { - t.Error("Expected HTML title to be in place") - } + // Display form + request, _ := http.NewRequest("GET", "/new", strings.NewReader("")) + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if !strings.Contains(response.Content, "Create New Post - A Fantastic Journal") { + t.Error("Expected HTML title to be in place") + } - // Display error when cookie was set - response.Reset() - controller.Init(container, []string{"", "0"}, request) - controller.Session().AddFlash("error") - controller.SaveSession(response) - request, _ = http.NewRequest("GET", "/new", strings.NewReader("")) - request.Header.Add("Cookie", response.Headers.Get("Set-Cookie")) - controller.Init(container, []string{"", "0"}, request) - response.Reset() - controller.Run(response, request) - if !strings.Contains(response.Content, " 0 { - http.Redirect(response, request, "/"+randomJournal.Slug, http.StatusFound) - } else { - http.Redirect(response, request, "/", http.StatusFound) - } + // Redirect to the entry or home page if none found + if randomJournal.ID > 0 { + http.Redirect(response, request, "/"+randomJournal.Slug, http.StatusFound) + } else { + http.Redirect(response, request, "/", http.StatusFound) + } } diff --git a/internal/app/controller/web/random_test.go b/internal/app/controller/web/random_test.go index 15ca517..c0b1412 100644 --- a/internal/app/controller/web/random_test.go +++ b/internal/app/controller/web/random_test.go @@ -1,48 +1,48 @@ package web import ( - "net/http" - "strings" - "testing" + "net/http" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestRandom_Run(t *testing.T) { - response := controller.NewMockResponse() - db := &database.MockSqlite{} - container := &app.Container{Db: db} - random := &Random{} - random.DisableTracking() - - // Test with a journal found - db.Rows = &database.MockJournal_SingleRow{} - request, _ := http.NewRequest("GET", "/random", strings.NewReader("")) - random.Init(container, []string{}, request) - random.Run(response, request) - - if response.StatusCode != http.StatusFound { - t.Errorf("Expected redirect, got status %d", response.StatusCode) - } - - if location := response.Headers.Get("Location"); location != "/slug" { - t.Errorf("Expected redirect to /slug, got %s", location) - } - - // Test with no journal found - response = controller.NewMockResponse() - db.Rows = &database.MockRowsEmpty{} - request, _ = http.NewRequest("GET", "/random", strings.NewReader("")) - random.Init(container, []string{}, request) - random.Run(response, request) - - if response.StatusCode != http.StatusFound { - t.Errorf("Expected redirect, got status %d", response.StatusCode) - } - - if location := response.Headers.Get("Location"); location != "/" { - t.Errorf("Expected redirect to /, got %s", location) - } + response := controller.NewMockResponse() + db := &database.MockSqlite{} + container := &app.Container{Db: db} + random := &Random{} + random.DisableTracking() + + // Test with a journal found + db.Rows = &database.MockJournal_SingleRow{} + request, _ := http.NewRequest("GET", "/random", strings.NewReader("")) + random.Init(container, []string{}, request) + random.Run(response, request) + + if response.StatusCode != http.StatusFound { + t.Errorf("Expected redirect, got status %d", response.StatusCode) + } + + if location := response.Headers.Get("Location"); location != "/slug" { + t.Errorf("Expected redirect to /slug, got %s", location) + } + + // Test with no journal found + response = controller.NewMockResponse() + db.Rows = &database.MockRowsEmpty{} + request, _ = http.NewRequest("GET", "/random", strings.NewReader("")) + random.Init(container, []string{}, request) + random.Run(response, request) + + if response.StatusCode != http.StatusFound { + t.Errorf("Expected redirect, got status %d", response.StatusCode) + } + + if location := response.Headers.Get("Location"); location != "/" { + t.Errorf("Expected redirect to /, got %s", location) + } } diff --git a/internal/app/controller/web/sitemap.go b/internal/app/controller/web/sitemap.go index 5734379..b1474c4 100644 --- a/internal/app/controller/web/sitemap.go +++ b/internal/app/controller/web/sitemap.go @@ -1,35 +1,35 @@ package web import ( - "net/http" - "text/template" + "net/http" + "text/template" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" ) // Sitemap Generate an XML sitemap type Sitemap struct { - controller.Super + controller.Super } type sitemapTemplateData struct { - Host string - Journals []model.Journal + Host string + Journals []model.Journal } // Run Sitemap func (c *Sitemap) Run(response http.ResponseWriter, request *http.Request) { - data := sitemapTemplateData{} - container := c.Super.Container().(*app.Container) - data.Host = request.Host - js := model.Journals{Container: container} + data := sitemapTemplateData{} + container := c.Super.Container().(*app.Container) + data.Host = request.Host + js := model.Journals{Container: container} - data.Journals = js.FetchAll() + data.Journals = js.FetchAll() - response.Header().Add("Content-type", "text/xml") - template, _ := template.ParseFiles("./web/templates/sitemap.xml.tmpl") - template.ExecuteTemplate(response, "content", data) + response.Header().Add("Content-type", "text/xml") + template, _ := template.ParseFiles("./web/templates/sitemap.xml.tmpl") + template.ExecuteTemplate(response, "content", data) } diff --git a/internal/app/controller/web/sitemap_test.go b/internal/app/controller/web/sitemap_test.go index efbff12..5bc6e79 100644 --- a/internal/app/controller/web/sitemap_test.go +++ b/internal/app/controller/web/sitemap_test.go @@ -1,43 +1,43 @@ package web import ( - "net/http" - "os" - "path" - "runtime" - "strings" - "testing" + "net/http" + "os" + "path" + "runtime" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "../../../..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } } func TestSitemap_Run(t *testing.T) { - db := &database.MockSqlite{} - configuration := app.DefaultConfiguration() - container := &app.Container{Configuration: configuration, Db: db} - response := controller.NewMockResponse() - controller := &Sitemap{} - controller.DisableTracking() + db := &database.MockSqlite{} + configuration := app.DefaultConfiguration() + container := &app.Container{Configuration: configuration, Db: db} + response := controller.NewMockResponse() + controller := &Sitemap{} + controller.DisableTracking() - // Test showing all Journals in sitemap - db.Rows = &database.MockJournal_MultipleRows{} - request, _ := http.NewRequest("GET", "/sitemap.xml", strings.NewReader("")) - request.Host = "example.com" - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) + // Test showing all Journals in sitemap + db.Rows = &database.MockJournal_MultipleRows{} + request, _ := http.NewRequest("GET", "/sitemap.xml", strings.NewReader("")) + request.Host = "example.com" + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) - if !strings.Contains(response.Content, "https://example.com/slug") || !strings.Contains(response.Content, "https://example.com/slug-2") { - t.Error("Expected all journals to be rendered in sitemap") - } + if !strings.Contains(response.Content, "https://example.com/slug") || !strings.Contains(response.Content, "https://example.com/slug-2") { + t.Error("Expected all journals to be rendered in sitemap") + } } diff --git a/internal/app/controller/web/stats.go b/internal/app/controller/web/stats.go index 2f8d2fe..84a2535 100644 --- a/internal/app/controller/web/stats.go +++ b/internal/app/controller/web/stats.go @@ -1,68 +1,68 @@ package web import ( - "net/http" - "text/template" + "net/http" + "text/template" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" ) // Stats Handle displaying journal stats and configuration type Stats struct { - controller.Super + controller.Super } type statsTemplateData struct { - Container *app.Container - PostCount int - FirstPostDate string - TitleSet bool - DescriptionSet bool - ThemeSet bool - PostsPerPage int - GACodeSet bool - CreateEnabled bool - EditEnabled bool - DailyVisits []model.DailyVisit - MonthlyVisits []model.MonthlyVisit + Container *app.Container + PostCount int + FirstPostDate string + TitleSet bool + DescriptionSet bool + ThemeSet bool + PostsPerPage int + GACodeSet bool + CreateEnabled bool + EditEnabled bool + DailyVisits []model.DailyVisit + MonthlyVisits []model.MonthlyVisit } // Run Stats action func (c *Stats) Run(response http.ResponseWriter, request *http.Request) { - data := statsTemplateData{} + data := statsTemplateData{} - container := c.Super.Container().(*app.Container) - data.Container = container + container := c.Super.Container().(*app.Container) + data.Container = container - js := model.Journals{Container: container} - allJournals := js.FetchAll() - data.PostCount = len(allJournals) + js := model.Journals{Container: container} + allJournals := js.FetchAll() + data.PostCount = len(allJournals) - if data.PostCount > 0 { - firstPost := allJournals[data.PostCount-1] - data.FirstPostDate = firstPost.GetDate() - } else { - data.FirstPostDate = "No posts yet" - } + if data.PostCount > 0 { + firstPost := allJournals[data.PostCount-1] + data.FirstPostDate = firstPost.GetDate() + } else { + data.FirstPostDate = "No posts yet" + } - // Settings status - defaultConfig := app.DefaultConfiguration() - data.TitleSet = container.Configuration.Title != defaultConfig.Title - data.DescriptionSet = container.Configuration.Description != defaultConfig.Description - data.ThemeSet = container.Configuration.Theme != defaultConfig.Theme - data.PostsPerPage = container.Configuration.PostsPerPage - data.GACodeSet = container.Configuration.GoogleAnalyticsCode != "" - data.CreateEnabled = container.Configuration.EnableCreate - data.EditEnabled = container.Configuration.EnableEdit + // Settings status + defaultConfig := app.DefaultConfiguration() + data.TitleSet = container.Configuration.Title != defaultConfig.Title + data.DescriptionSet = container.Configuration.Description != defaultConfig.Description + data.ThemeSet = container.Configuration.Theme != defaultConfig.Theme + data.PostsPerPage = container.Configuration.PostsPerPage + data.GACodeSet = container.Configuration.GoogleAnalyticsCode != "" + data.CreateEnabled = container.Configuration.EnableCreate + data.EditEnabled = container.Configuration.EnableEdit - vs := model.Visits{Container: container} - data.DailyVisits = vs.GetDailyStats(14) - data.MonthlyVisits = vs.GetMonthlyStats() + vs := model.Visits{Container: container} + data.DailyVisits = vs.GetDailyStats(14) + data.MonthlyVisits = vs.GetMonthlyStats() - template, _ := template.ParseFiles( - "./web/templates/_layout/default.html.tmpl", - "./web/templates/stats.html.tmpl") - template.ExecuteTemplate(response, "layout", data) + template, _ := template.ParseFiles( + "./web/templates/_layout/default.html.tmpl", + "./web/templates/stats.html.tmpl") + template.ExecuteTemplate(response, "layout", data) } diff --git a/internal/app/controller/web/stats_test.go b/internal/app/controller/web/stats_test.go index e413f80..cac8243 100644 --- a/internal/app/controller/web/stats_test.go +++ b/internal/app/controller/web/stats_test.go @@ -1,60 +1,60 @@ package web import ( - "net/http" - "strings" - "testing" + "net/http" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestStats_Run(t *testing.T) { - db := &database.MockSqlite{} - configuration := app.DefaultConfiguration() - configuration.PostsPerPage = 25 - configuration.GoogleAnalyticsCode = "UA-123456" - container := &app.Container{Configuration: configuration, Db: db} - response := controller.NewMockResponse() - controller := &Stats{} - controller.DisableTracking() - - // Test with journals - db.Rows = &database.MockJournal_MultipleRows{} - request, _ := http.NewRequest("GET", "/stats", strings.NewReader("")) - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - - if !strings.Contains(response.Content, "

Stats

") { - t.Error("Expected stats page title to be displayed") - } - - if !strings.Contains(response.Content, "
Total Posts
\n
2
") { - t.Error("Expected post count to be displayed") - } - - if !strings.Contains(response.Content, "
First Post Date
") { - t.Error("Expected first post date to be displayed") - } - - if !strings.Contains(response.Content, "
Posts Per Page
\n
25
") { - t.Error("Expected custom posts per page setting to be displayed") - } - - if !strings.Contains(response.Content, "
Google Analytics
\n
Enabled
") { - t.Error("Expected GA code to be displayed as enabled") - } - - response.Reset() - db.Rows = &database.MockRowsEmpty{} - controller.Run(response, request) - - if !strings.Contains(response.Content, "
Total Posts
\n
0
") { - t.Error("Expected post count to be 0") - } - - if !strings.Contains(response.Content, "
First Post Date
\n
No posts yet
") { - t.Error("Expected 'No posts yet' message for first post date") - } + db := &database.MockSqlite{} + configuration := app.DefaultConfiguration() + configuration.PostsPerPage = 25 + configuration.GoogleAnalyticsCode = "UA-123456" + container := &app.Container{Configuration: configuration, Db: db} + response := controller.NewMockResponse() + controller := &Stats{} + controller.DisableTracking() + + // Test with journals + db.Rows = &database.MockJournal_MultipleRows{} + request, _ := http.NewRequest("GET", "/stats", strings.NewReader("")) + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + + if !strings.Contains(response.Content, "

Stats

") { + t.Error("Expected stats page title to be displayed") + } + + if !strings.Contains(response.Content, "
Total Posts
\n
2
") { + t.Error("Expected post count to be displayed") + } + + if !strings.Contains(response.Content, "
First Post Date
") { + t.Error("Expected first post date to be displayed") + } + + if !strings.Contains(response.Content, "
Posts Per Page
\n
25
") { + t.Error("Expected custom posts per page setting to be displayed") + } + + if !strings.Contains(response.Content, "
Google Analytics
\n
Enabled
") { + t.Error("Expected GA code to be displayed as enabled") + } + + response.Reset() + db.Rows = &database.MockRowsEmpty{} + controller.Run(response, request) + + if !strings.Contains(response.Content, "
Total Posts
\n
0
") { + t.Error("Expected post count to be 0") + } + + if !strings.Contains(response.Content, "
First Post Date
\n
No posts yet
") { + t.Error("Expected 'No posts yet' message for first post date") + } } diff --git a/internal/app/controller/web/view.go b/internal/app/controller/web/view.go index d0e10a8..e0758f4 100644 --- a/internal/app/controller/web/view.go +++ b/internal/app/controller/web/view.go @@ -1,43 +1,43 @@ package web import ( - "net/http" - "text/template" + "net/http" + "text/template" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/controller" ) // View Handle displaying individual entry type View struct { - controller.Super + controller.Super } type viewTemplateData struct { - Container interface{} - Journal model.Journal - Next model.Journal - Prev model.Journal + Container interface{} + Journal model.Journal + Next model.Journal + Prev model.Journal } // Run View action func (c *View) Run(response http.ResponseWriter, request *http.Request) { - data := viewTemplateData{} - container := c.Super.Container().(*app.Container) - data.Container = container - js := model.Journals{Container: container} - data.Journal = js.FindBySlug(c.Params()[1]) + data := viewTemplateData{} + container := c.Super.Container().(*app.Container) + data.Container = container + js := model.Journals{Container: container} + data.Journal = js.FindBySlug(c.Params()[1]) - if data.Journal.ID == 0 { - RunBadRequest(response, request, container) - } else { - data.Next = js.FindNext(data.Journal.ID) - data.Prev = js.FindPrev(data.Journal.ID) - template, _ := template.ParseFiles( - "./web/templates/_layout/default.html.tmpl", - "./web/templates/view.html.tmpl") - template.ExecuteTemplate(response, "layout", data) - } + if data.Journal.ID == 0 { + RunBadRequest(response, request, container) + } else { + data.Next = js.FindNext(data.Journal.ID) + data.Prev = js.FindPrev(data.Journal.ID) + template, _ := template.ParseFiles( + "./web/templates/_layout/default.html.tmpl", + "./web/templates/view.html.tmpl") + template.ExecuteTemplate(response, "layout", data) + } } diff --git a/internal/app/controller/web/view_test.go b/internal/app/controller/web/view_test.go index 721dd7f..40f7b30 100644 --- a/internal/app/controller/web/view_test.go +++ b/internal/app/controller/web/view_test.go @@ -1,78 +1,78 @@ package web import ( - "net/http" - "os" - "path" - "runtime" - "strings" - "testing" + "net/http" + "os" + "path" + "runtime" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "../../../..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } } func TestView_Run(t *testing.T) { - db := &database.MockSqlite{} - configuration := app.DefaultConfiguration() - container := &app.Container{Configuration: configuration, Db: db} - response := controller.NewMockResponse() - controller := &View{} - controller.DisableTracking() + db := &database.MockSqlite{} + configuration := app.DefaultConfiguration() + container := &app.Container{Configuration: configuration, Db: db} + response := controller.NewMockResponse() + controller := &View{} + controller.DisableTracking() - // Test not found/error with GET/POST - db.Rows = &database.MockRowsEmpty{} - request := &http.Request{Method: "GET"} - controller.Init(container, []string{"", "0"}, request) - controller.Run(response, request) - if response.StatusCode != 404 || !strings.Contains(response.Content, "Page Not Found") { - t.Error("Expected 404 error when journal not found") - } + // Test not found/error with GET/POST + db.Rows = &database.MockRowsEmpty{} + request := &http.Request{Method: "GET"} + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if response.StatusCode != 404 || !strings.Contains(response.Content, "Page Not Found") { + t.Error("Expected 404 error when journal not found") + } - // Display no error - response.Reset() - request, _ = http.NewRequest("GET", "/slug", strings.NewReader("")) - db.Rows = &database.MockJournal_SingleRow{} - controller.Run(response, request) - if strings.Contains(response.Content, "div class=\"error\"") || !strings.Contains(response.Content, "Content") { - t.Error("Expected no error to be shown in page") - } - if !strings.Contains(response.Content, "Title - A Fantastic Journal") { - t.Error("Expected HTML title to be in place") - } + // Display no error + response.Reset() + request, _ = http.NewRequest("GET", "/slug", strings.NewReader("")) + db.Rows = &database.MockJournal_SingleRow{} + controller.Run(response, request) + if strings.Contains(response.Content, "div class=\"error\"") || !strings.Contains(response.Content, "Content") { + t.Error("Expected no error to be shown in page") + } + if !strings.Contains(response.Content, "Title - A Fantastic Journal") { + t.Error("Expected HTML title to be in place") + } - // Display prev & next strings - response.Reset() - request, _ = http.NewRequest("GET", "/slug", strings.NewReader("")) - db.EnableMultiMode() - db.AppendResult(&database.MockJournal_SingleRow{}) - db.AppendResult(&database.MockJournal_SingleRow{}) - db.AppendResult(&database.MockJournal_SingleRow{}) - controller.Run(response, request) - if !strings.Contains(response.Content, ">Previous<") || !strings.Contains(response.Content, ">Next<") { - t.Error("Expected previous and next links to be shown in page") - } + // Display prev & next strings + response.Reset() + request, _ = http.NewRequest("GET", "/slug", strings.NewReader("")) + db.EnableMultiMode() + db.AppendResult(&database.MockJournal_SingleRow{}) + db.AppendResult(&database.MockJournal_SingleRow{}) + db.AppendResult(&database.MockJournal_SingleRow{}) + controller.Run(response, request) + 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 labels are displayed when timestamps are present - 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, "Created:") || !strings.Contains(response.Content, "Last Updated:") { - t.Error("Expected timestamp labels to be displayed when timestamps are present") - } + // Test that timestamp labels are displayed when timestamps are present + 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, "Created:") || !strings.Contains(response.Content, "Last Updated:") { + t.Error("Expected timestamp labels to be displayed when timestamps are present") + } } diff --git a/internal/app/model/journal.go b/internal/app/model/journal.go index cbeedda..4a90425 100644 --- a/internal/app/model/journal.go +++ b/internal/app/model/journal.go @@ -1,340 +1,340 @@ package model import ( - "database/sql" - "fmt" - "math" - "math/rand" - "regexp" - "strconv" - "strings" - "time" - - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/pkg/database" - "github.com/jamiefdhurst/journal/pkg/database/rows" - "github.com/jamiefdhurst/journal/pkg/markdown" + "database/sql" + "fmt" + "math" + "math/rand" + "regexp" + "strconv" + "strings" + "time" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/pkg/database" + "github.com/jamiefdhurst/journal/pkg/database/rows" + "github.com/jamiefdhurst/journal/pkg/markdown" ) 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 - CreatedAt *time.Time `json:"created_at"` // Automatically managed - UpdatedAt *time.Time `json:"updated_at"` // Automatically managed + 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 func (j Journal) GetHTML() string { - // This method will be rendered in templates, so we need a version that doesn't need container access - // In production, use the container's MarkdownProcessor when available through other methods - markdownProcessor := &markdown.Markdown{} - return markdownProcessor.ToHTML(j.Content) + // This method will be rendered in templates, so we need a version that doesn't need container access + // In production, use the container's MarkdownProcessor when available through other methods + markdownProcessor := &markdown.Markdown{} + return markdownProcessor.ToHTML(j.Content) } // GetDate Get the friendly date for the Journal func (j Journal) GetDate() string { - re := regexp.MustCompile(`\d{4}\-\d{2}\-\d{2}`) - date := re.FindString(j.Date) - timeObj, err := time.Parse("2006-01-02", date) - if err != nil { - return "" - } - return timeObj.Format("Monday January 2, 2006") + re := regexp.MustCompile(`\d{4}\-\d{2}\-\d{2}`) + date := re.FindString(j.Date) + timeObj, err := time.Parse("2006-01-02", date) + if err != nil { + return "" + } + return timeObj.Format("Monday January 2, 2006") } // GetEditableDate Get the date string for editing func (j Journal) GetEditableDate() string { - re := regexp.MustCompile(`\d{4}\-\d{2}\-\d{2}`) - return re.FindString(j.Date) + re := regexp.MustCompile(`\d{4}\-\d{2}\-\d{2}`) + 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") + 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") + 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 == "" { - return "" - } - - // Split content by paragraphs to preserve newlines - paragraphs := strings.Split(j.Content, "\n\n") - - // Process each paragraph - wordCount := 0 - resultParagraphs := []string{} - - for _, paragraph := range paragraphs { - // Skip if we've already got 50+ words - if wordCount >= maxWords { - break - } - - // Process the paragraph - lines := strings.Split(paragraph, "\n") - resultLines := []string{} - - for _, line := range lines { - lineWords := strings.Fields(line) - - // Calculate how many words we can take from this line - wordsToTake := maxWords - wordCount - if wordsToTake <= 0 { - break - } - - if len(lineWords) > wordsToTake { - lineWords = lineWords[:wordsToTake] - resultLines = append(resultLines, strings.Join(lineWords, " ")+"...") - wordCount += wordsToTake - break - } else { - resultLines = append(resultLines, strings.Join(lineWords, " ")) - wordCount += len(lineWords) - } - } - - // Join the lines back together and add to result paragraphs - if len(resultLines) > 0 { - resultParagraphs = append(resultParagraphs, strings.Join(resultLines, "\n")) - } - } - - // Join the paragraphs with double newlines for markdown - excerpt := strings.Join(resultParagraphs, "\n\n") - - // Create a temporary markdown formatter and convert to HTML - markdownProcessor := &markdown.Markdown{} - return markdownProcessor.ToHTML(excerpt) + if j.Content == "" { + return "" + } + + // Split content by paragraphs to preserve newlines + paragraphs := strings.Split(j.Content, "\n\n") + + // Process each paragraph + wordCount := 0 + resultParagraphs := []string{} + + for _, paragraph := range paragraphs { + // Skip if we've already got 50+ words + if wordCount >= maxWords { + break + } + + // Process the paragraph + lines := strings.Split(paragraph, "\n") + resultLines := []string{} + + for _, line := range lines { + lineWords := strings.Fields(line) + + // Calculate how many words we can take from this line + wordsToTake := maxWords - wordCount + if wordsToTake <= 0 { + break + } + + if len(lineWords) > wordsToTake { + lineWords = lineWords[:wordsToTake] + resultLines = append(resultLines, strings.Join(lineWords, " ")+"...") + wordCount += wordsToTake + break + } else { + resultLines = append(resultLines, strings.Join(lineWords, " ")) + wordCount += len(lineWords) + } + } + + // Join the lines back together and add to result paragraphs + if len(resultLines) > 0 { + resultParagraphs = append(resultParagraphs, strings.Join(resultLines, "\n")) + } + } + + // Join the paragraphs with double newlines for markdown + excerpt := strings.Join(resultParagraphs, "\n\n") + + // Create a temporary markdown formatter and convert to HTML + markdownProcessor := &markdown.Markdown{} + return markdownProcessor.ToHTML(excerpt) } // Journals Common database resource link for Journal actions type Journals struct { - Container *app.Container + Container *app.Container } // CreateTable Create the actual table func (js *Journals) CreateTable() error { - _, err := js.Container.Db.Exec("CREATE TABLE IF NOT EXISTS `" + journalTable + "` (" + - "`id` INTEGER PRIMARY KEY AUTOINCREMENT, " + - "`slug` VARCHAR(255) NOT NULL, " + - "`title` VARCHAR(255) NOT NULL, " + - "`date` DATE NOT NULL, " + - "`content` TEXT NOT NULL" + - ")") - - return err + _, err := js.Container.Db.Exec("CREATE TABLE IF NOT EXISTS `" + journalTable + "` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT, " + + "`slug` VARCHAR(255) NOT NULL, " + + "`title` VARCHAR(255) NOT NULL, " + + "`date` DATE NOT NULL, " + + "`content` TEXT NOT NULL" + + ")") + + return err } // EnsureUniqueSlug Make sure the current slug is unique func (js *Journals) EnsureUniqueSlug(slug string, addition int) string { - newSlug := slug - if addition > 0 { - newSlug = strings.Join([]string{slug, "-", strconv.Itoa(addition)}, "") - } - exists := js.FindBySlug(newSlug) - if exists.ID > 0 { - addition++ - return js.EnsureUniqueSlug(slug, addition) - } - - return newSlug + newSlug := slug + if addition > 0 { + newSlug = strings.Join([]string{slug, "-", strconv.Itoa(addition)}, "") + } + exists := js.FindBySlug(newSlug) + if exists.ID > 0 { + addition++ + return js.EnsureUniqueSlug(slug, addition) + } + + return newSlug } // FetchAll Get all journals func (js *Journals) FetchAll() []Journal { - rows, err := js.Container.Db.Query("SELECT * FROM `" + journalTable + "` ORDER BY `date` DESC") - if err != nil { - return []Journal{} - } + rows, err := js.Container.Db.Query("SELECT * FROM `" + journalTable + "` ORDER BY `date` DESC") + if err != nil { + return []Journal{} + } - return js.loadFromRows(rows) + return js.loadFromRows(rows) } // FetchByDate Get all journal entries on a given date func (js *Journals) FetchByDate(date string) []Journal { - rows, err := js.Container.Db.Query("SELECT * FROM `"+journalTable+"` WHERE `date` LIKE ? ORDER BY `id`", date+"%") - if err != nil { - return []Journal{} - } + rows, err := js.Container.Db.Query("SELECT * FROM `"+journalTable+"` WHERE `date` LIKE ? ORDER BY `id`", date+"%") + if err != nil { + return []Journal{} + } - return js.loadFromRows(rows) + return js.loadFromRows(rows) } // FetchPaginated returns a set of paginated journal entries func (js *Journals) FetchPaginated(query database.PaginationQuery) ([]Journal, database.PaginationInformation) { - pagination := database.PaginationInformation{ - Page: query.Page, - ResultsPerPage: query.ResultsPerPage, - } - - countResult, err := js.Container.Db.Query("SELECT COUNT(*) AS `total` FROM `" + journalTable + "`") - if err != nil { - return []Journal{}, pagination - } - countResult.Next() - countResult.Scan(&pagination.TotalResults) - countResult.Close() - pagination.TotalPages = int(math.Ceil(float64(pagination.TotalResults) / float64(query.ResultsPerPage))) - - if query.Page > pagination.TotalPages { - return []Journal{}, pagination - } - - rows, _ := js.Container.Db.Query(fmt.Sprintf("SELECT * FROM `"+journalTable+"` ORDER BY `date` DESC LIMIT %d OFFSET %d", query.ResultsPerPage, (query.Page-1)*query.ResultsPerPage)) - return js.loadFromRows(rows), pagination + pagination := database.PaginationInformation{ + Page: query.Page, + ResultsPerPage: query.ResultsPerPage, + } + + countResult, err := js.Container.Db.Query("SELECT COUNT(*) AS `total` FROM `" + journalTable + "`") + if err != nil { + return []Journal{}, pagination + } + countResult.Next() + countResult.Scan(&pagination.TotalResults) + countResult.Close() + pagination.TotalPages = int(math.Ceil(float64(pagination.TotalResults) / float64(query.ResultsPerPage))) + + if query.Page > pagination.TotalPages { + return []Journal{}, pagination + } + + rows, _ := js.Container.Db.Query(fmt.Sprintf("SELECT * FROM `"+journalTable+"` ORDER BY `date` DESC LIMIT %d OFFSET %d", query.ResultsPerPage, (query.Page-1)*query.ResultsPerPage)) + return js.loadFromRows(rows), pagination } // FindBySlug Find a journal by slug func (js *Journals) FindBySlug(slug string) Journal { - return js.loadSingle(js.Container.Db.Query("SELECT * FROM `"+journalTable+"` WHERE `slug` = ? LIMIT 1", slug)) + return js.loadSingle(js.Container.Db.Query("SELECT * FROM `"+journalTable+"` WHERE `slug` = ? LIMIT 1", slug)) } // FindNext returns the next entry after an ID func (js *Journals) FindNext(id int) Journal { - return js.loadSingle(js.Container.Db.Query("SELECT * FROM `"+journalTable+"` WHERE `id` > ? ORDER BY `id` LIMIT 1", strconv.Itoa(id))) + return js.loadSingle(js.Container.Db.Query("SELECT * FROM `"+journalTable+"` WHERE `id` > ? ORDER BY `id` LIMIT 1", strconv.Itoa(id))) } // FindPrev returns the previous entry before an ID func (js *Journals) FindPrev(id int) Journal { - return js.loadSingle(js.Container.Db.Query("SELECT * FROM `"+journalTable+"` WHERE `id` < ? ORDER BY `id` DESC LIMIT 1", strconv.Itoa(id))) + return js.loadSingle(js.Container.Db.Query("SELECT * FROM `"+journalTable+"` WHERE `id` < ? ORDER BY `id` DESC LIMIT 1", strconv.Itoa(id))) } // FindRandom returns a random journal entry func (js *Journals) FindRandom() Journal { - allEntries := js.FetchAll() - if len(allEntries) == 0 { - return Journal{} - } + allEntries := js.FetchAll() + if len(allEntries) == 0 { + return Journal{} + } - // In a production environment, select a random entry - seed := time.Now().UnixNano() - r := rand.New(rand.NewSource(seed)) - randomIndex := r.Intn(len(allEntries)) + // In a production environment, select a random entry + seed := time.Now().UnixNano() + r := rand.New(rand.NewSource(seed)) + randomIndex := r.Intn(len(allEntries)) - return allEntries[randomIndex] + return allEntries[randomIndex] } // Save Save a journal entry, either inserting it or updating it in the database func (js *Journals) Save(j Journal) Journal { - var res sql.Result - - // Convert content for saving - if j.Slug == "" { - j.Slug = Slugify(j.Title) - } - - // Ensure the slug is not reserved - if !ValidateSlug(j.Slug) { - // Append a number to the slug to make it valid - 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`, `created_at`, `updated_at`) VALUES(?,?,?,?,?,?)", j.Slug, j.Title, j.Date, j.Content, j.CreatedAt, j.UpdatedAt) - } else { - // 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 - if j.ID == 0 { - id, _ := res.LastInsertId() - j.ID = int(id) - } - - return j + var res sql.Result + + // Convert content for saving + if j.Slug == "" { + j.Slug = Slugify(j.Title) + } + + // Ensure the slug is not reserved + if !ValidateSlug(j.Slug) { + // Append a number to the slug to make it valid + 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`, `created_at`, `updated_at`) VALUES(?,?,?,?,?,?)", j.Slug, j.Title, j.Date, j.Content, j.CreatedAt, j.UpdatedAt) + } else { + // 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 + if j.ID == 0 { + id, _ := res.LastInsertId() + j.ID = int(id) + } + + return j } func (js Journals) loadFromRows(rows rows.Rows) []Journal { - defer rows.Close() - journals := []Journal{} - for rows.Next() { - j := Journal{} - rows.Scan(&j.ID, &j.Slug, &j.Title, &j.Date, &j.Content, &j.CreatedAt, &j.UpdatedAt) - journals = append(journals, j) - } - - return journals + defer rows.Close() + journals := []Journal{} + for rows.Next() { + j := Journal{} + rows.Scan(&j.ID, &j.Slug, &j.Title, &j.Date, &j.Content, &j.CreatedAt, &j.UpdatedAt) + journals = append(journals, j) + } + + return journals } func (js *Journals) loadSingle(rows rows.Rows, err error) Journal { - if err != nil { - return Journal{} - } - journals := js.loadFromRows(rows) + if err != nil { + return Journal{} + } + journals := js.loadFromRows(rows) - if len(journals) == 1 { - return journals[0] - } + if len(journals) == 1 { + return journals[0] + } - return Journal{} + return Journal{} } // Slugify Utility to convert a string into a slug func Slugify(s string) string { - re := regexp.MustCompile(`[\W+]`) + re := regexp.MustCompile(`[\W+]`) - return strings.ToLower(re.ReplaceAllString(s, "-")) + return strings.ToLower(re.ReplaceAllString(s, "-")) } // ValidateSlug ensures a slug is acceptable func ValidateSlug(slug string) bool { - // Check for reserved slugs - if slug == "random" || slug == "new" || slug == "stats" { - return false - } + // Check for reserved slugs + if slug == "random" || slug == "new" || slug == "stats" { + return false + } - // Check for slugs that would conflict with API routes - if strings.HasPrefix(slug, "api/") { - return false - } + // Check for slugs that would conflict with API routes + if strings.HasPrefix(slug, "api/") { + return false + } - return true + return true } // Validate data for inserting or updating a journal func Validate(title string, date string, content string) bool { - if title == "" || len(title) < 3 { - return false - } - re := regexp.MustCompile(`^\d{4}\-\d{2}\-\d{2}`) - if date == "" || !re.Match([]byte(date)) { - return false - } - if content == "" || len(content) < 3 { - return false - } - - // Generate a slug and validate it - slug := Slugify(title) - - return ValidateSlug(slug) + if title == "" || len(title) < 3 { + return false + } + re := regexp.MustCompile(`^\d{4}\-\d{2}\-\d{2}`) + if date == "" || !re.Match([]byte(date)) { + return false + } + if content == "" || len(content) < 3 { + return false + } + + // Generate a slug and validate it + slug := Slugify(title) + + return ValidateSlug(slug) } diff --git a/internal/app/model/journal_test.go b/internal/app/model/journal_test.go index ee194d4..e584187 100644 --- a/internal/app/model/journal_test.go +++ b/internal/app/model/journal_test.go @@ -1,451 +1,451 @@ package model import ( - "testing" - "time" + "testing" + "time" - "github.com/jamiefdhurst/journal/internal/app" - pkgDb "github.com/jamiefdhurst/journal/pkg/database" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + pkgDb "github.com/jamiefdhurst/journal/pkg/database" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestJournal_GetDate(t *testing.T) { - tables := []struct { - input string - output string - }{ - {"2018-05-10", "Thursday May 10, 2018"}, - {"200-00-00", ""}, - {"", ""}, - {"0000-00-00", ""}, - } - - for _, table := range tables { - j := Journal{Date: table.input} - actual := j.GetDate() - if actual != table.output { - t.Errorf("Expected GetDate() to produce result of '%s', got '%s'", table.output, actual) - } - } + tables := []struct { + input string + output string + }{ + {"2018-05-10", "Thursday May 10, 2018"}, + {"200-00-00", ""}, + {"", ""}, + {"0000-00-00", ""}, + } + + for _, table := range tables { + j := Journal{Date: table.input} + actual := j.GetDate() + if actual != table.output { + t.Errorf("Expected GetDate() to produce result of '%s', got '%s'", table.output, actual) + } + } } func TestJournal_GetEditableDate(t *testing.T) { - tables := []struct { - input string - output string - }{ - {"2018-05-10", "2018-05-10"}, - {"2018-05-10EXTRATHINGS", "2018-05-10"}, - {"200-00-00", ""}, - {"", ""}, - {"0000-00-00", "0000-00-00"}, - } - - for _, table := range tables { - j := Journal{Date: table.input} - actual := j.GetEditableDate() - if actual != table.output { - t.Errorf("Expected GetEditableDate() to produce result of '%s', got '%s'", table.output, actual) - } - } + tables := []struct { + input string + output string + }{ + {"2018-05-10", "2018-05-10"}, + {"2018-05-10EXTRATHINGS", "2018-05-10"}, + {"200-00-00", ""}, + {"", ""}, + {"0000-00-00", "0000-00-00"}, + } + + for _, table := range tables { + j := Journal{Date: table.input} + actual := j.GetEditableDate() + if actual != table.output { + t.Errorf("Expected GetEditableDate() to produce result of '%s', got '%s'", table.output, actual) + } + } } func TestJournal_GetHTMLExcerpt(t *testing.T) { - tables := []struct { - input string - output string - }{ - {"Some **bold** text", "

Some bold text

\n"}, - {"Multiple\n\nparagraphs", "

Multiple

\n\n

paragraphs

\n"}, - {"", ""}, - {"*Italic* and **bold**", "

Italic and bold

\n"}, - {"Line 1\nLine 2\nLine 3", "

Line 1\nLine 2\nLine 3

\n"}, - } - - for _, table := range tables { - j := Journal{Content: table.input} - actual := j.GetHTMLExcerpt(50) - if actual != table.output { - t.Errorf("Expected GetHTMLExcerpt() to produce result of '%s', got '%s'", table.output, actual) - } - } + tables := []struct { + input string + output string + }{ + {"Some **bold** text", "

Some bold text

\n"}, + {"Multiple\n\nparagraphs", "

Multiple

\n\n

paragraphs

\n"}, + {"", ""}, + {"*Italic* and **bold**", "

Italic and bold

\n"}, + {"Line 1\nLine 2\nLine 3", "

Line 1\nLine 2\nLine 3

\n"}, + } + + for _, table := range tables { + j := Journal{Content: table.input} + actual := j.GetHTMLExcerpt(50) + if actual != table.output { + t.Errorf("Expected GetHTMLExcerpt() to produce result of '%s', got '%s'", table.output, actual) + } + } } func TestJournal_GetHTMLExcerpt_ShortWords(t *testing.T) { - tables := []struct { - input string - output string - }{ - {"Some **bold** text", "

Some bold

\n"}, - {"Multiple\n\nparagraphs", "

Multiple

\n\n

paragraphs

\n"}, - {"", ""}, - {"*Italic* and **bold**", "

Italic and…

\n"}, - {"Line 1\nLine 2\nLine 3", "

Line 1

\n"}, - } - - for _, table := range tables { - j := Journal{Content: table.input} - actual := j.GetHTMLExcerpt(2) - if actual != table.output { - t.Errorf("Expected GetHTMLExcerpt() to produce result of '%s', got '%s'", table.output, actual) - } - } + tables := []struct { + input string + output string + }{ + {"Some **bold** text", "

Some bold

\n"}, + {"Multiple\n\nparagraphs", "

Multiple

\n\n

paragraphs

\n"}, + {"", ""}, + {"*Italic* and **bold**", "

Italic and…

\n"}, + {"Line 1\nLine 2\nLine 3", "

Line 1

\n"}, + } + + for _, table := range tables { + j := Journal{Content: table.input} + actual := j.GetHTMLExcerpt(2) + if actual != table.output { + t.Errorf("Expected GetHTMLExcerpt() to produce result of '%s', got '%s'", table.output, actual) + } + } } func TestJournals_CreateTable(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - js := Journals{Container: container} - js.CreateTable() - if db.Queries != 1 { - t.Errorf("Expected 1 query to have been run") - } + db := &database.MockSqlite{} + container := &app.Container{Db: db} + js := Journals{Container: container} + js.CreateTable() + if db.Queries != 1 { + t.Errorf("Expected 1 query to have been run") + } } func TestJournals_EnsureUniqueSlug(t *testing.T) { - db := &database.MockSqlite{} - db.ErrorMode = false - container := &app.Container{Db: db} - js := Journals{Container: container} - - // Test no result - db.Rows = &database.MockRowsEmpty{} - actual := js.EnsureUniqueSlug("test", 0) - if actual != "test" { - t.Errorf("Expected EnsureUniqueSlug() to produce result of '%s', got '%s'", "test", actual) - } - - // Test simple - db.Rows = &database.MockJournal_SingleRow{} - db.ExpectedArgument = "test" - actual = js.EnsureUniqueSlug("test", 0) - if actual != "test-1" { - t.Errorf("Expected EnsureUniqueSlug() to produce result of '%s', got '%s'", "test-1", actual) - } - - db.Rows = &database.MockJournal_SingleRow{} - db.ExpectedArgument = "test-2" - actual = js.EnsureUniqueSlug("test", 2) - if actual != "test-3" { - t.Errorf("Expected EnsureUniqueSlug() to produce result of '%s', got '%s'", "test-3", actual) - } + db := &database.MockSqlite{} + db.ErrorMode = false + container := &app.Container{Db: db} + js := Journals{Container: container} + + // Test no result + db.Rows = &database.MockRowsEmpty{} + actual := js.EnsureUniqueSlug("test", 0) + if actual != "test" { + t.Errorf("Expected EnsureUniqueSlug() to produce result of '%s', got '%s'", "test", actual) + } + + // Test simple + db.Rows = &database.MockJournal_SingleRow{} + db.ExpectedArgument = "test" + actual = js.EnsureUniqueSlug("test", 0) + if actual != "test-1" { + t.Errorf("Expected EnsureUniqueSlug() to produce result of '%s', got '%s'", "test-1", actual) + } + + db.Rows = &database.MockJournal_SingleRow{} + db.ExpectedArgument = "test-2" + actual = js.EnsureUniqueSlug("test", 2) + if actual != "test-3" { + t.Errorf("Expected EnsureUniqueSlug() to produce result of '%s', got '%s'", "test-3", actual) + } } func TestJournals_FetchAll(t *testing.T) { - // Test error - db := &database.MockSqlite{} - db.ErrorMode = true - container := &app.Container{Db: db} - js := Journals{Container: container} - journals := js.FetchAll() - if len(journals) > 0 { - t.Errorf("Expected empty result set returned when error received") - } - - // Test empty result - db.ErrorMode = false - db.Rows = &database.MockRowsEmpty{} - journals = js.FetchAll() - if len(journals) > 0 { - t.Errorf("Expected empty result set returned") - } - - // Test successful result - db.Rows = &database.MockJournal_MultipleRows{} - journals = js.FetchAll() - if len(journals) < 2 || journals[0].ID != 1 || journals[1].Content != "Content 2" { - t.Errorf("Expected 2 rows returned and with correct data") - } + // Test error + db := &database.MockSqlite{} + db.ErrorMode = true + container := &app.Container{Db: db} + js := Journals{Container: container} + journals := js.FetchAll() + if len(journals) > 0 { + t.Errorf("Expected empty result set returned when error received") + } + + // Test empty result + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + journals = js.FetchAll() + if len(journals) > 0 { + t.Errorf("Expected empty result set returned") + } + + // Test successful result + db.Rows = &database.MockJournal_MultipleRows{} + journals = js.FetchAll() + if len(journals) < 2 || journals[0].ID != 1 || journals[1].Content != "Content 2" { + t.Errorf("Expected 2 rows returned and with correct data") + } } func TestJournals_FetchByDate(t *testing.T) { - // Test error - db := &database.MockSqlite{} - db.ErrorMode = true - container := &app.Container{Db: db} - js := Journals{Container: container} - journals := js.FetchByDate("2001-01-01") - if len(journals) > 0 { - t.Errorf("Expected empty result set returned when error received") - } - - // Test empty result - db.ErrorMode = false - db.Rows = &database.MockRowsEmpty{} - journals = js.FetchByDate("2001-01-01") - if len(journals) > 0 { - t.Errorf("Expected empty result set returned") - } - - // Test successful result - db.Rows = &database.MockJournal_MultipleRows{} - journals = js.FetchByDate("2001-01-01") - if len(journals) < 2 || journals[0].ID != 1 || journals[1].Content != "Content 2" { - t.Errorf("Expected 2 rows returned and with correct data") - } + // Test error + db := &database.MockSqlite{} + db.ErrorMode = true + container := &app.Container{Db: db} + js := Journals{Container: container} + journals := js.FetchByDate("2001-01-01") + if len(journals) > 0 { + t.Errorf("Expected empty result set returned when error received") + } + + // Test empty result + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + journals = js.FetchByDate("2001-01-01") + if len(journals) > 0 { + t.Errorf("Expected empty result set returned") + } + + // Test successful result + db.Rows = &database.MockJournal_MultipleRows{} + journals = js.FetchByDate("2001-01-01") + if len(journals) < 2 || journals[0].ID != 1 || journals[1].Content != "Content 2" { + t.Errorf("Expected 2 rows returned and with correct data") + } } func TestJournals_FetchPaginated(t *testing.T) { - // Test error - db := &database.MockSqlite{} - db.ErrorMode = true - container := &app.Container{Db: db} - js := Journals{Container: container} - journals, pagination := js.FetchPaginated(pkgDb.PaginationQuery{Page: 1, ResultsPerPage: 2}) - if len(journals) > 0 || pagination.TotalPages > 0 { - t.Error("Expected empty result set returned when error received") - } - - // Test empty result - db.ErrorMode = false - db.Rows = &database.MockPagination_Result{TotalResults: 0} - journals, pagination = js.FetchPaginated(pkgDb.PaginationQuery{Page: 1, ResultsPerPage: 2}) - if len(journals) > 0 || pagination.TotalPages > 0 { - t.Error("Expected empty result set returned when no pages received") - } - - // Test pages out of bounds - db.Rows = &database.MockPagination_Result{TotalResults: 2} - journals, pagination = js.FetchPaginated(pkgDb.PaginationQuery{Page: 4, ResultsPerPage: 2}) - if len(journals) > 0 || pagination.TotalPages != 1 { - t.Errorf("Expected empty result set with correct pages returned, instead received +%v", pagination) - } - - // Test successful result - db.EnableMultiMode() - db.AppendResult(&database.MockPagination_Result{TotalResults: 4}) - db.AppendResult(&database.MockJournal_MultipleRows{}) - journals, pagination = js.FetchPaginated(pkgDb.PaginationQuery{Page: 1, ResultsPerPage: 2}) - if len(journals) != 2 || journals[0].ID != 1 || journals[1].Content != "Content 2" || pagination.TotalPages != 2 || pagination.TotalResults != 4 { - t.Errorf("Expected 2 rows returned and with correct data") - } + // Test error + db := &database.MockSqlite{} + db.ErrorMode = true + container := &app.Container{Db: db} + js := Journals{Container: container} + journals, pagination := js.FetchPaginated(pkgDb.PaginationQuery{Page: 1, ResultsPerPage: 2}) + if len(journals) > 0 || pagination.TotalPages > 0 { + t.Error("Expected empty result set returned when error received") + } + + // Test empty result + db.ErrorMode = false + db.Rows = &database.MockPagination_Result{TotalResults: 0} + journals, pagination = js.FetchPaginated(pkgDb.PaginationQuery{Page: 1, ResultsPerPage: 2}) + if len(journals) > 0 || pagination.TotalPages > 0 { + t.Error("Expected empty result set returned when no pages received") + } + + // Test pages out of bounds + db.Rows = &database.MockPagination_Result{TotalResults: 2} + journals, pagination = js.FetchPaginated(pkgDb.PaginationQuery{Page: 4, ResultsPerPage: 2}) + if len(journals) > 0 || pagination.TotalPages != 1 { + t.Errorf("Expected empty result set with correct pages returned, instead received +%v", pagination) + } + + // Test successful result + db.EnableMultiMode() + db.AppendResult(&database.MockPagination_Result{TotalResults: 4}) + db.AppendResult(&database.MockJournal_MultipleRows{}) + journals, pagination = js.FetchPaginated(pkgDb.PaginationQuery{Page: 1, ResultsPerPage: 2}) + if len(journals) != 2 || journals[0].ID != 1 || journals[1].Content != "Content 2" || pagination.TotalPages != 2 || pagination.TotalResults != 4 { + t.Errorf("Expected 2 rows returned and with correct data") + } } func TestJournals_FindBySlug(t *testing.T) { - // Test error - db := &database.MockSqlite{} - db.ErrorMode = true - container := &app.Container{Db: db} - js := Journals{Container: container} - journal := js.FindBySlug("example") - if journal.ID > 0 { - t.Errorf("Expected empty result set returned when error received") - } - - // Test empty result - db.ErrorMode = false - db.Rows = &database.MockRowsEmpty{} - journal = js.FindBySlug("example") - if journal.ID > 0 { - t.Errorf("Expected empty result set returned") - } - - // Test successful result - db.Rows = &database.MockJournal_SingleRow{} - db.ExpectedArgument = "slug" - journal = js.FindBySlug("slug") - if journal.ID != 1 || journal.Content != "Content" { - t.Errorf("Expected 1 row returned and with correct data") - } - - // Test unexpected amount of rows - db.Rows = &database.MockJournal_MultipleRows{} - journal = js.FindBySlug("slug") - if journal.ID > 0 { - t.Errorf("Expected no rows when query returns more than one result") - } + // Test error + db := &database.MockSqlite{} + db.ErrorMode = true + container := &app.Container{Db: db} + js := Journals{Container: container} + journal := js.FindBySlug("example") + if journal.ID > 0 { + t.Errorf("Expected empty result set returned when error received") + } + + // Test empty result + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + journal = js.FindBySlug("example") + if journal.ID > 0 { + t.Errorf("Expected empty result set returned") + } + + // Test successful result + db.Rows = &database.MockJournal_SingleRow{} + db.ExpectedArgument = "slug" + journal = js.FindBySlug("slug") + if journal.ID != 1 || journal.Content != "Content" { + t.Errorf("Expected 1 row returned and with correct data") + } + + // Test unexpected amount of rows + db.Rows = &database.MockJournal_MultipleRows{} + journal = js.FindBySlug("slug") + if journal.ID > 0 { + t.Errorf("Expected no rows when query returns more than one result") + } } func TestJournals_FindNext(t *testing.T) { - // Test error - db := &database.MockSqlite{} - db.ErrorMode = true - container := &app.Container{Db: db} - js := Journals{Container: container} - journal := js.FindNext(100) - if journal.ID > 0 { - t.Error("Expected empty result set returned when error received") - } - - // Test empty result - db.ErrorMode = false - db.Rows = &database.MockRowsEmpty{} - journal = js.FindNext(100) - if journal.ID > 0 { - t.Error("Expected empty result set returned") - } - - // Test successful result - db.Rows = &database.MockJournal_SingleRow{} - db.ExpectedArgument = "0" - journal = js.FindNext(0) - if journal.ID != 1 || journal.Content != "Content" { - t.Error("Expected 1 row returned and with correct data") - } - - // Test unexpected amount of rows - db.Rows = &database.MockJournal_MultipleRows{} - journal = js.FindNext(0) - if journal.ID > 0 { - t.Error("Expected no rows when query returns more than one result") - } + // Test error + db := &database.MockSqlite{} + db.ErrorMode = true + container := &app.Container{Db: db} + js := Journals{Container: container} + journal := js.FindNext(100) + if journal.ID > 0 { + t.Error("Expected empty result set returned when error received") + } + + // Test empty result + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + journal = js.FindNext(100) + if journal.ID > 0 { + t.Error("Expected empty result set returned") + } + + // Test successful result + db.Rows = &database.MockJournal_SingleRow{} + db.ExpectedArgument = "0" + journal = js.FindNext(0) + if journal.ID != 1 || journal.Content != "Content" { + t.Error("Expected 1 row returned and with correct data") + } + + // Test unexpected amount of rows + db.Rows = &database.MockJournal_MultipleRows{} + journal = js.FindNext(0) + if journal.ID > 0 { + t.Error("Expected no rows when query returns more than one result") + } } func TestJournals_FindPrev(t *testing.T) { - // Test error - db := &database.MockSqlite{} - db.ErrorMode = true - container := &app.Container{Db: db} - js := Journals{Container: container} - journal := js.FindPrev(100) - if journal.ID > 0 { - t.Error("Expected empty result set returned when error received") - } - - // Test empty result - db.ErrorMode = false - db.Rows = &database.MockRowsEmpty{} - journal = js.FindPrev(100) - if journal.ID > 0 { - t.Error("Expected empty result set returned") - } - - // Test successful result - db.Rows = &database.MockJournal_SingleRow{} - db.ExpectedArgument = "2" - journal = js.FindPrev(2) - if journal.ID != 1 || journal.Content != "Content" { - t.Error("Expected 1 row returned and with correct data") - } - - // Test unexpected amount of rows - db.Rows = &database.MockJournal_MultipleRows{} - journal = js.FindPrev(0) - if journal.ID > 0 { - t.Error("Expected no rows when query returns more than one result") - } + // Test error + db := &database.MockSqlite{} + db.ErrorMode = true + container := &app.Container{Db: db} + js := Journals{Container: container} + journal := js.FindPrev(100) + if journal.ID > 0 { + t.Error("Expected empty result set returned when error received") + } + + // Test empty result + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + journal = js.FindPrev(100) + if journal.ID > 0 { + t.Error("Expected empty result set returned") + } + + // Test successful result + db.Rows = &database.MockJournal_SingleRow{} + db.ExpectedArgument = "2" + journal = js.FindPrev(2) + if journal.ID != 1 || journal.Content != "Content" { + t.Error("Expected 1 row returned and with correct data") + } + + // Test unexpected amount of rows + db.Rows = &database.MockJournal_MultipleRows{} + journal = js.FindPrev(0) + if journal.ID > 0 { + t.Error("Expected no rows when query returns more than one result") + } } func TestJournals_Save(t *testing.T) { - db := &database.MockSqlite{Result: &database.MockResult{}} - db.Rows = &database.MockRowsEmpty{} - container := &app.Container{Db: db} - js := Journals{Container: container} - - // Test with new Journal - journal := js.Save(Journal{ID: 0, Title: "Testing"}) - if journal.ID != 1 || journal.Title != "Testing" { - t.Error("Expected same Journal to have been returned with new ID") - } - - // Test with same Journal - journal = js.Save(Journal{ID: 2, Title: "Testing 2"}) - if journal.ID != 2 || journal.Title != "Testing 2" { - t.Error("Expected same Journal to have been returned with new ID") - } + db := &database.MockSqlite{Result: &database.MockResult{}} + db.Rows = &database.MockRowsEmpty{} + container := &app.Container{Db: db} + js := Journals{Container: container} + + // Test with new Journal + journal := js.Save(Journal{ID: 0, Title: "Testing"}) + if journal.ID != 1 || journal.Title != "Testing" { + t.Error("Expected same Journal to have been returned with new ID") + } + + // Test with same Journal + journal = js.Save(Journal{ID: 2, Title: "Testing 2"}) + if journal.ID != 2 || journal.Title != "Testing 2" { + t.Error("Expected same Journal to have been returned with new ID") + } } func TestSlugify(t *testing.T) { - tables := []struct { - input string - output string - }{ - {"A SIMPLE TITLE", "a-simple-title"}, - {"already-slugified", "already-slugified"}, - {" ", "---"}, - {"lower cased", "lower-cased"}, - {"Special!!!Characters@$%^&*(", "special---characters-------"}, - } - - for _, table := range tables { - actual := Slugify(table.input) - if actual != table.output { - t.Errorf("Expected Slugify() to produce result of '%s', got '%s'", table.output, actual) - } - } + tables := []struct { + input string + output string + }{ + {"A SIMPLE TITLE", "a-simple-title"}, + {"already-slugified", "already-slugified"}, + {" ", "---"}, + {"lower cased", "lower-cased"}, + {"Special!!!Characters@$%^&*(", "special---characters-------"}, + } + + for _, table := range tables { + actual := Slugify(table.input) + if actual != table.output { + t.Errorf("Expected Slugify() to produce result of '%s', got '%s'", table.output, actual) + } + } } 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) - } + // 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) - } + // 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 + 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 1d6c1b6..c9d2da3 100644 --- a/internal/app/model/migration.go +++ b/internal/app/model/migration.go @@ -1,197 +1,197 @@ package model import ( - "database/sql" - "fmt" - "log" + "database/sql" + "fmt" + "log" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/pkg/database/rows" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/pkg/database/rows" ) const migrationTable = "migration" // Migration stores a record of migrations that have been applied type Migration struct { - ID int `json:"id"` - Name string `json:"name"` - Applied bool `json:"applied"` + ID int `json:"id"` + Name string `json:"name"` + Applied bool `json:"applied"` } // Migrations manages database migrations type Migrations struct { - Container *app.Container + Container *app.Container } // CreateTable initializes the migrations table func (ms *Migrations) CreateTable() error { - _, err := ms.Container.Db.Exec("CREATE TABLE IF NOT EXISTS `" + migrationTable + "` (" + - "`id` INTEGER PRIMARY KEY AUTOINCREMENT, " + - "`name` VARCHAR(255) NOT NULL, " + - "`applied` BOOLEAN NOT NULL DEFAULT 0" + - ")") + _, err := ms.Container.Db.Exec("CREATE TABLE IF NOT EXISTS `" + migrationTable + "` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT, " + + "`name` VARCHAR(255) NOT NULL, " + + "`applied` BOOLEAN NOT NULL DEFAULT 0" + + ")") - return err + return err } // HasMigrationRun checks if a specific migration has been applied func (ms *Migrations) HasMigrationRun(name string) bool { - rows, err := ms.Container.Db.Query("SELECT * FROM `"+migrationTable+"` WHERE `name` = ? LIMIT 1", name) - if err != nil { - return false - } + rows, err := ms.Container.Db.Query("SELECT * FROM `"+migrationTable+"` WHERE `name` = ? LIMIT 1", name) + if err != nil { + return false + } - migrations := ms.loadFromRows(rows) + migrations := ms.loadFromRows(rows) - return len(migrations) > 0 && migrations[0].Applied + return len(migrations) > 0 && migrations[0].Applied } // RecordMigration marks a migration as applied func (ms *Migrations) RecordMigration(name string) error { - // Check if migration exists first - rows, err := ms.Container.Db.Query("SELECT * FROM `"+migrationTable+"` WHERE `name` = ? LIMIT 1", name) - if err != nil { - return err - } - - migrations := ms.loadFromRows(rows) - - var res sql.Result - if len(migrations) == 0 { - // Create new migration record - res, err = ms.Container.Db.Exec("INSERT INTO `"+migrationTable+"` (`name`, `applied`) VALUES(?, ?)", name, true) - } else { - // Update existing migration record - res, err = ms.Container.Db.Exec("UPDATE `"+migrationTable+"` SET `applied` = ? WHERE `id` = ?", true, migrations[0].ID) - } - - if err != nil { - return err - } - - _, err = res.RowsAffected() - return err + // Check if migration exists first + rows, err := ms.Container.Db.Query("SELECT * FROM `"+migrationTable+"` WHERE `name` = ? LIMIT 1", name) + if err != nil { + return err + } + + migrations := ms.loadFromRows(rows) + + var res sql.Result + if len(migrations) == 0 { + // Create new migration record + res, err = ms.Container.Db.Exec("INSERT INTO `"+migrationTable+"` (`name`, `applied`) VALUES(?, ?)", name, true) + } else { + // Update existing migration record + res, err = ms.Container.Db.Exec("UPDATE `"+migrationTable+"` SET `applied` = ? WHERE `id` = ?", true, migrations[0].ID) + } + + if err != nil { + return err + } + + _, err = res.RowsAffected() + return err } func (ms *Migrations) loadFromRows(rows rows.Rows) []Migration { - defer rows.Close() - migrations := []Migration{} - for rows.Next() { - migration := Migration{} - rows.Scan(&migration.ID, &migration.Name, &migration.Applied) - migrations = append(migrations, migration) - } - - return migrations + defer rows.Close() + migrations := []Migration{} + for rows.Next() { + migration := Migration{} + rows.Scan(&migration.ID, &migration.Name, &migration.Applied) + migrations = append(migrations, migration) + } + + return migrations } // MigrateHTMLToMarkdown converts all journal entries from HTML to Markdown func (ms *Migrations) MigrateHTMLToMarkdown() error { - const migrationName = "html_to_markdown" + const migrationName = "html_to_markdown" - // Skip if already migrated - if ms.HasMigrationRun(migrationName) { - log.Println("HTML to Markdown migration already applied. Skipping...") - return nil - } + // Skip if already migrated + if ms.HasMigrationRun(migrationName) { + log.Println("HTML to Markdown migration already applied. Skipping...") + return nil + } - log.Println("Running HTML to Markdown migration...") + log.Println("Running HTML to Markdown migration...") - // Get all journal entries - js := Journals{Container: ms.Container} - journalEntries := js.FetchAll() + // Get all journal entries + js := Journals{Container: ms.Container} + journalEntries := js.FetchAll() - log.Printf("Found %d journal entries to migrate\n", len(journalEntries)) + log.Printf("Found %d journal entries to migrate\n", len(journalEntries)) - count := 0 - for _, journal := range journalEntries { - // Convert HTML content to Markdown - markdownContent := ms.Container.MarkdownProcessor.FromHTML(journal.Content) - journal.Content = markdownContent + count := 0 + for _, journal := range journalEntries { + // Convert HTML content to Markdown + markdownContent := ms.Container.MarkdownProcessor.FromHTML(journal.Content) + journal.Content = markdownContent - // Save the entry with the new markdown content - js.Save(journal) - count++ + // Save the entry with the new markdown content + js.Save(journal) + count++ - log.Printf("Migrated entry: %s (%d)\n", journal.Title, journal.ID) - } + log.Printf("Migrated entry: %s (%d)\n", journal.Title, journal.ID) + } - log.Printf("Migration complete. Converted %d journal entries from HTML to Markdown.\n", count) + log.Printf("Migration complete. Converted %d journal entries from HTML to Markdown.\n", count) - // Record migration as completed - err := ms.RecordMigration(migrationName) - if err != nil { - return fmt.Errorf("migration completed but failed to record status: %w", err) - } + // 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 + return nil } // MigrateRandomSlugs fixes any journal entries that have the "random" slug func (ms *Migrations) MigrateRandomSlugs() error { - const migrationName = "random_slug_fix" - - // Skip if already migrated - if ms.HasMigrationRun(migrationName) { - log.Println("Random slug fix migration already applied. Skipping...") - return nil - } - - log.Println("Running random slug fix migration...") - - // Get the journal with the 'random' slug if it exists - js := Journals{Container: ms.Container} - randomJournal := js.FindBySlug("random") - - if randomJournal.ID == 0 { - log.Println("No journal entry found with 'random' slug. Migration not needed.") - } else { - // Rename the slug to 'random-post' - randomJournal.Slug = "random-post" - js.Save(randomJournal) - log.Printf("Migrated journal entry: %s (ID: %d) from 'random' to 'random-post'\n", randomJournal.Title, randomJournal.ID) - } - - // 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 + const migrationName = "random_slug_fix" + + // Skip if already migrated + if ms.HasMigrationRun(migrationName) { + log.Println("Random slug fix migration already applied. Skipping...") + return nil + } + + log.Println("Running random slug fix migration...") + + // Get the journal with the 'random' slug if it exists + js := Journals{Container: ms.Container} + randomJournal := js.FindBySlug("random") + + if randomJournal.ID == 0 { + log.Println("No journal entry found with 'random' slug. Migration not needed.") + } else { + // Rename the slug to 'random-post' + randomJournal.Slug = "random-post" + js.Save(randomJournal) + log.Printf("Migrated journal entry: %s (ID: %d) from 'random' to 'random-post'\n", randomJournal.Title, randomJournal.ID) + } + + // 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 } // MigrateAddTimestamps adds created_at and updated_at columns to the journal table func (ms *Migrations) MigrateAddTimestamps() error { - const migrationName = "add_timestamps" + const migrationName = "add_timestamps" - // Skip if already migrated - if ms.HasMigrationRun(migrationName) { - log.Println("Add timestamps migration already applied. Skipping...") - return nil - } + // Skip if already migrated + if ms.HasMigrationRun(migrationName) { + log.Println("Add timestamps migration already applied. Skipping...") + return nil + } - log.Println("Running add timestamps migration...") + 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 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) - } + // 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.") + 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) - } + // 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 + return nil } diff --git a/internal/app/model/migration_test.go b/internal/app/model/migration_test.go index c78804a..cf45cf7 100644 --- a/internal/app/model/migration_test.go +++ b/internal/app/model/migration_test.go @@ -1,111 +1,111 @@ package model import ( - "testing" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/pkg/markdown" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/pkg/markdown" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestMigrations_CreateTable(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - migrations := Migrations{Container: container} - migrations.CreateTable() - if db.Queries != 1 { - t.Errorf("Expected 1 query to have been run") - } + db := &database.MockSqlite{} + container := &app.Container{Db: db} + migrations := Migrations{Container: container} + migrations.CreateTable() + if db.Queries != 1 { + t.Errorf("Expected 1 query to have been run") + } } func TestMigrations_HasMigrationRun(t *testing.T) { - // Test error case - db := &database.MockSqlite{} - db.ErrorMode = true - container := &app.Container{Db: db} - migrations := Migrations{Container: container} + // Test error case + db := &database.MockSqlite{} + db.ErrorMode = true + container := &app.Container{Db: db} + migrations := Migrations{Container: container} - if migrations.HasMigrationRun("test_migration") { - t.Error("Should return false when database has an error") - } + if migrations.HasMigrationRun("test_migration") { + t.Error("Should return false when database has an error") + } - // Test migration not found - db.ErrorMode = false - db.Rows = &database.MockRowsEmpty{} + // Test migration not found + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} - if migrations.HasMigrationRun("test_migration") { - t.Error("Should return false when migration doesn't exist") - } + if migrations.HasMigrationRun("test_migration") { + t.Error("Should return false when migration doesn't exist") + } - // Create a mock for testing with a found migration - db2 := &database.MockSqlite{} - db2.Rows = &database.MockRowsEmpty{} - container2 := &app.Container{Db: db2} - migrations2 := Migrations{Container: container2} + // Create a mock for testing with a found migration + db2 := &database.MockSqlite{} + db2.Rows = &database.MockRowsEmpty{} + container2 := &app.Container{Db: db2} + migrations2 := Migrations{Container: container2} - // For this test, we'll just return a true value directly - // The real implementation would check if a record exists with applied=true - // but that's difficult to mock without modifying the mock objects - if migrations2.HasMigrationRun("test_migration") { - // This is just a placeholder test since we can't easily test the positive case - // without modifying the mock database objects - } + // For this test, we'll just return a true value directly + // The real implementation would check if a record exists with applied=true + // but that's difficult to mock without modifying the mock objects + if migrations2.HasMigrationRun("test_migration") { + // This is just a placeholder test since we can't easily test the positive case + // without modifying the mock database objects + } } func TestMigrations_RecordMigration(t *testing.T) { - // Test error on query - db := &database.MockSqlite{} - db.ErrorMode = true - container := &app.Container{Db: db} - migrations := Migrations{Container: container} + // Test error on query + db := &database.MockSqlite{} + db.ErrorMode = true + container := &app.Container{Db: db} + migrations := Migrations{Container: container} - err := migrations.RecordMigration("test_migration") - if err == nil { - t.Error("Should return error when database has an error on query") - } + err := migrations.RecordMigration("test_migration") + if err == nil { + t.Error("Should return error when database has an error on query") + } - // Test insert new migration - db.ErrorMode = false - db.Rows = &database.MockRowsEmpty{} - db.Result = &database.MockResult{} + // Test insert new migration + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + db.Result = &database.MockResult{} - err = migrations.RecordMigration("test_migration") - if err != nil { - t.Errorf("Should not return error when inserting: %v", err) - } + err = migrations.RecordMigration("test_migration") + if err != nil { + t.Errorf("Should not return error when inserting: %v", err) + } - // Since we're working with mocks, we can't easily test the update path - // without significantly modifying the mock implementation + // Since we're working with mocks, we can't easily test the update path + // without significantly modifying the mock implementation } func TestMigrations_MigrateHTMLToMarkdown(t *testing.T) { - // Setup mock database and container - db := &database.MockSqlite{} - db.EnableMultiMode() - - // Mock the migrations table query - show migration hasn't run yet - db.AppendResult(&database.MockRowsEmpty{}) - - // Mock the journal fetch query - db.AppendResult(&database.MockJournal_MultipleRows{}) - - // Mock the record migration queries - db.AppendResult(&database.MockRowsEmpty{}) - db.Result = &database.MockResult{} - - container := &app.Container{ - Db: db, - MarkdownProcessor: &markdown.Markdown{}, - } - - // Run the migration - migrations := Migrations{Container: container} - err := migrations.MigrateHTMLToMarkdown() - - if err != nil { - t.Errorf("Migration should run without errors: %v", err) - } - - // For testing the already-applied case, we'd need custom mock rows - // which is difficult with the current mock implementation + // Setup mock database and container + db := &database.MockSqlite{} + db.EnableMultiMode() + + // Mock the migrations table query - show migration hasn't run yet + db.AppendResult(&database.MockRowsEmpty{}) + + // Mock the journal fetch query + db.AppendResult(&database.MockJournal_MultipleRows{}) + + // Mock the record migration queries + db.AppendResult(&database.MockRowsEmpty{}) + db.Result = &database.MockResult{} + + container := &app.Container{ + Db: db, + MarkdownProcessor: &markdown.Markdown{}, + } + + // Run the migration + migrations := Migrations{Container: container} + err := migrations.MigrateHTMLToMarkdown() + + if err != nil { + t.Errorf("Migration should run without errors: %v", err) + } + + // For testing the already-applied case, we'd need custom mock rows + // which is difficult with the current mock implementation } \ No newline at end of file diff --git a/internal/app/model/visit.go b/internal/app/model/visit.go index d0e4950..2c1f301 100644 --- a/internal/app/model/visit.go +++ b/internal/app/model/visit.go @@ -1,166 +1,166 @@ package model import ( - "regexp" - "strconv" - "time" + "regexp" + "strconv" + "time" - "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app" ) const visitTable = "visit" // Visit stores a record of daily visits for a given endpoint/web address type Visit struct { - ID int `json:"id"` - Date string `json:"date"` - URL string `json:"url"` - Hits int `json:"hits"` + ID int `json:"id"` + Date string `json:"date"` + URL string `json:"url"` + Hits int `json:"hits"` } // Visits manages tracking API hits type Visits struct { - Container *app.Container + Container *app.Container } // CreateTable initializes the visits table func (vs *Visits) CreateTable() error { - _, err := vs.Container.Db.Exec("CREATE TABLE IF NOT EXISTS `" + visitTable + "` (" + - "`id` INTEGER PRIMARY KEY AUTOINCREMENT, " + - "`date` DATE NOT NULL, " + - "`url` VARCHAR(255) NOT NULL, " + - "`hits` INTEGER UNSIGNED NOT NULL DEFAULT 0" + - ")") - - return err + _, err := vs.Container.Db.Exec("CREATE TABLE IF NOT EXISTS `" + visitTable + "` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT, " + + "`date` DATE NOT NULL, " + + "`url` VARCHAR(255) NOT NULL, " + + "`hits` INTEGER UNSIGNED NOT NULL DEFAULT 0" + + ")") + + return err } // FindByDateAndURL finds a visit record for a specific date and URL func (vs *Visits) FindByDateAndURL(date, url string) Visit { - visit := Visit{} - rows, err := vs.Container.Db.Query("SELECT * FROM `"+visitTable+"` WHERE `date` = ? AND `url` = ? LIMIT 1", date, url) - if err != nil { - return visit - } - defer rows.Close() - - if rows.Next() { - rows.Scan(&visit.ID, &visit.Date, &visit.URL, &visit.Hits) - return visit - } - - return Visit{} + visit := Visit{} + rows, err := vs.Container.Db.Query("SELECT * FROM `"+visitTable+"` WHERE `date` = ? AND `url` = ? LIMIT 1", date, url) + if err != nil { + return visit + } + defer rows.Close() + + if rows.Next() { + rows.Scan(&visit.ID, &visit.Date, &visit.URL, &visit.Hits) + return visit + } + + return Visit{} } // RecordVisit records or updates a visit for the given URL and current date func (vs *Visits) RecordVisit(url string) error { - today := time.Now().Format("2006-01-02") + today := time.Now().Format("2006-01-02") - existingVisit := vs.FindByDateAndURL(today, url) - var err error - if existingVisit.ID > 0 { - _, err = vs.Container.Db.Exec("UPDATE `"+visitTable+"` SET `hits` = `hits` + 1 WHERE `id` = ?", strconv.Itoa(existingVisit.ID)) - } else { - _, err = vs.Container.Db.Exec("INSERT INTO `"+visitTable+"` (`date`, `url`, `hits`) VALUES (?, ?, 1)", today, url) - } + existingVisit := vs.FindByDateAndURL(today, url) + var err error + if existingVisit.ID > 0 { + _, err = vs.Container.Db.Exec("UPDATE `"+visitTable+"` SET `hits` = `hits` + 1 WHERE `id` = ?", strconv.Itoa(existingVisit.ID)) + } else { + _, err = vs.Container.Db.Exec("INSERT INTO `"+visitTable+"` (`date`, `url`, `hits`) VALUES (?, ?, 1)", today, url) + } - return err + return err } // DailyVisit represents daily visit statistics type DailyVisit struct { - Date string `json:"date"` - APIHits int `json:"api_hits"` - WebHits int `json:"web_hits"` - Total int `json:"total"` + Date string `json:"date"` + APIHits int `json:"api_hits"` + WebHits int `json:"web_hits"` + Total int `json:"total"` } // GetFriendlyDate returns a human-readable date format func (d DailyVisit) GetFriendlyDate() string { - re := regexp.MustCompile(`\d{4}\-\d{2}\-\d{2}`) - date := re.FindString(d.Date) - timeObj, err := time.Parse("2006-01-02", date) - if err != nil { - return d.Date - } - return timeObj.Format("Monday January 2, 2006") + re := regexp.MustCompile(`\d{4}\-\d{2}\-\d{2}`) + date := re.FindString(d.Date) + timeObj, err := time.Parse("2006-01-02", date) + if err != nil { + return d.Date + } + return timeObj.Format("Monday January 2, 2006") } // MonthlyVisit represents monthly visit statistics type MonthlyVisit struct { - Month string `json:"month"` - APIHits int `json:"api_hits"` - WebHits int `json:"web_hits"` - Total int `json:"total"` + Month string `json:"month"` + APIHits int `json:"api_hits"` + WebHits int `json:"web_hits"` + Total int `json:"total"` } // GetFriendlyMonth returns a human-readable month format func (m MonthlyVisit) GetFriendlyMonth() string { - timeObj, err := time.Parse("2006-01", m.Month) - if err != nil { - return m.Month - } - return timeObj.Format("January 2006") + timeObj, err := time.Parse("2006-01", m.Month) + if err != nil { + return m.Month + } + return timeObj.Format("January 2006") } // GetDailyStats returns visit statistics for the last N days func (vs *Visits) GetDailyStats(days int) []DailyVisit { - // Calculate the date N days ago - startDate := time.Now().AddDate(0, 0, -days+1).Format("2006-01-02") - - query := ` - SELECT - DATE(date), - COALESCE(SUM(CASE WHEN url LIKE '/api/%' THEN hits ELSE 0 END), 0) as api_hits, - COALESCE(SUM(CASE WHEN url NOT LIKE '/api/%' THEN hits ELSE 0 END), 0) as web_hits, - COALESCE(SUM(hits), 0) as total - FROM ` + visitTable + ` - WHERE date >= ? - GROUP BY date - ORDER BY date DESC - ` - - rows, err := vs.Container.Db.Query(query, startDate) - if err != nil { - return []DailyVisit{} - } - defer rows.Close() - - var dailyStats []DailyVisit - for rows.Next() { - var stat DailyVisit - rows.Scan(&stat.Date, &stat.APIHits, &stat.WebHits, &stat.Total) - dailyStats = append(dailyStats, stat) - } - - return dailyStats + // Calculate the date N days ago + startDate := time.Now().AddDate(0, 0, -days+1).Format("2006-01-02") + + query := ` + SELECT + DATE(date), + COALESCE(SUM(CASE WHEN url LIKE '/api/%' THEN hits ELSE 0 END), 0) as api_hits, + COALESCE(SUM(CASE WHEN url NOT LIKE '/api/%' THEN hits ELSE 0 END), 0) as web_hits, + COALESCE(SUM(hits), 0) as total + FROM ` + visitTable + ` + WHERE date >= ? + GROUP BY date + ORDER BY date DESC + ` + + rows, err := vs.Container.Db.Query(query, startDate) + if err != nil { + return []DailyVisit{} + } + defer rows.Close() + + var dailyStats []DailyVisit + for rows.Next() { + var stat DailyVisit + rows.Scan(&stat.Date, &stat.APIHits, &stat.WebHits, &stat.Total) + dailyStats = append(dailyStats, stat) + } + + return dailyStats } // GetMonthlyStats returns visit statistics aggregated by month func (vs *Visits) GetMonthlyStats() []MonthlyVisit { - query := ` - SELECT - strftime('%Y-%m', date) as month, - COALESCE(SUM(CASE WHEN url LIKE '/api/%' THEN hits ELSE 0 END), 0) as api_hits, - COALESCE(SUM(CASE WHEN url NOT LIKE '/api/%' THEN hits ELSE 0 END), 0) as web_hits, - COALESCE(SUM(hits), 0) as total - FROM ` + visitTable + ` - GROUP BY strftime('%Y-%m', date) - ORDER BY month DESC - ` - - rows, err := vs.Container.Db.Query(query) - if err != nil { - return []MonthlyVisit{} - } - defer rows.Close() - - var monthlyStats []MonthlyVisit - for rows.Next() { - var stat MonthlyVisit - rows.Scan(&stat.Month, &stat.APIHits, &stat.WebHits, &stat.Total) - monthlyStats = append(monthlyStats, stat) - } - - return monthlyStats + query := ` + SELECT + strftime('%Y-%m', date) as month, + COALESCE(SUM(CASE WHEN url LIKE '/api/%' THEN hits ELSE 0 END), 0) as api_hits, + COALESCE(SUM(CASE WHEN url NOT LIKE '/api/%' THEN hits ELSE 0 END), 0) as web_hits, + COALESCE(SUM(hits), 0) as total + FROM ` + visitTable + ` + GROUP BY strftime('%Y-%m', date) + ORDER BY month DESC + ` + + rows, err := vs.Container.Db.Query(query) + if err != nil { + return []MonthlyVisit{} + } + defer rows.Close() + + var monthlyStats []MonthlyVisit + for rows.Next() { + var stat MonthlyVisit + rows.Scan(&stat.Month, &stat.APIHits, &stat.WebHits, &stat.Total) + monthlyStats = append(monthlyStats, stat) + } + + return monthlyStats } diff --git a/internal/app/model/visit_test.go b/internal/app/model/visit_test.go index b553830..6d4778a 100644 --- a/internal/app/model/visit_test.go +++ b/internal/app/model/visit_test.go @@ -1,153 +1,153 @@ package model import ( - "testing" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestVisits_CreateTable(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - visits := Visits{Container: container} + db := &database.MockSqlite{} + container := &app.Container{Db: db} + visits := Visits{Container: container} - err := visits.CreateTable() + err := visits.CreateTable() - if err != nil { - t.Errorf("Expected no error creating table, got: %s", err) - } + if err != nil { + t.Errorf("Expected no error creating table, got: %s", err) + } } func TestVisits_FindByDateAndURL(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - visits := Visits{Container: container} - - db.Rows = &database.MockVisit_SingleRow{} - visit := visits.FindByDateAndURL("2023-01-01", "/test") - - if visit.ID != 1 { - t.Errorf("Expected visit ID to be 1, got %d", visit.ID) - } - if visit.URL != "/test" { - t.Errorf("Expected visit URL to be /test, got %s", visit.URL) - } - if visit.Hits != 5 { - t.Errorf("Expected visit hits to be 5, got %d", visit.Hits) - } - - // Test with no visit found - db.Rows = &database.MockRowsEmpty{} - emptyVisit := visits.FindByDateAndURL("2023-01-01", "/nonexistent") - - if emptyVisit.ID != 0 { - t.Errorf("Expected empty visit ID to be 0, got %d", emptyVisit.ID) - } + db := &database.MockSqlite{} + container := &app.Container{Db: db} + visits := Visits{Container: container} + + db.Rows = &database.MockVisit_SingleRow{} + visit := visits.FindByDateAndURL("2023-01-01", "/test") + + if visit.ID != 1 { + t.Errorf("Expected visit ID to be 1, got %d", visit.ID) + } + if visit.URL != "/test" { + t.Errorf("Expected visit URL to be /test, got %s", visit.URL) + } + if visit.Hits != 5 { + t.Errorf("Expected visit hits to be 5, got %d", visit.Hits) + } + + // Test with no visit found + db.Rows = &database.MockRowsEmpty{} + emptyVisit := visits.FindByDateAndURL("2023-01-01", "/nonexistent") + + if emptyVisit.ID != 0 { + t.Errorf("Expected empty visit ID to be 0, got %d", emptyVisit.ID) + } } func TestVisits_RecordVisit(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - visits := Visits{Container: container} + db := &database.MockSqlite{} + container := &app.Container{Db: db} + visits := Visits{Container: container} - db.Rows = &database.MockRowsEmpty{} // No existing visit - db.Result = &database.MockResult{} + db.Rows = &database.MockRowsEmpty{} // No existing visit + db.Result = &database.MockResult{} - err := visits.RecordVisit("/new-page") + err := visits.RecordVisit("/new-page") - if err != nil { - t.Errorf("Expected no error recording new visit, got: %s", err) - } + if err != nil { + t.Errorf("Expected no error recording new visit, got: %s", err) + } - db.Rows = &database.MockVisit_SingleRow{} // Existing visit - db.Result = &database.MockResult{} + db.Rows = &database.MockVisit_SingleRow{} // Existing visit + db.Result = &database.MockResult{} - err = visits.RecordVisit("/test") + err = visits.RecordVisit("/test") - if err != nil { - t.Errorf("Expected no error updating existing visit, got: %s", err) - } + if err != nil { + t.Errorf("Expected no error updating existing visit, got: %s", err) + } } func TestVisits_GetDailyStats(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - visits := Visits{Container: container} - - // Test with mock data - db.Rows = &database.MockVisitStats_DailyRows{} - - dailyStats := visits.GetDailyStats(14) - - if len(dailyStats) != 2 { - t.Errorf("Expected 2 daily stats, got %d", len(dailyStats)) - } - - if len(dailyStats) > 0 { - if dailyStats[0].Date != "2023-12-25" { - t.Errorf("Expected first date to be 2023-12-25, got %s", dailyStats[0].Date) - } - if dailyStats[0].Total != 57 { - t.Errorf("Expected first total to be 57, got %d", dailyStats[0].Total) - } - } + db := &database.MockSqlite{} + container := &app.Container{Db: db} + visits := Visits{Container: container} + + // Test with mock data + db.Rows = &database.MockVisitStats_DailyRows{} + + dailyStats := visits.GetDailyStats(14) + + if len(dailyStats) != 2 { + t.Errorf("Expected 2 daily stats, got %d", len(dailyStats)) + } + + if len(dailyStats) > 0 { + if dailyStats[0].Date != "2023-12-25" { + t.Errorf("Expected first date to be 2023-12-25, got %s", dailyStats[0].Date) + } + if dailyStats[0].Total != 57 { + t.Errorf("Expected first total to be 57, got %d", dailyStats[0].Total) + } + } } func TestVisits_GetMonthlyStats(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - visits := Visits{Container: container} - - // Test with mock data - db.Rows = &database.MockVisitStats_MonthlyRows{} - - monthlyStats := visits.GetMonthlyStats() - - if len(monthlyStats) != 2 { - t.Errorf("Expected 2 monthly stats, got %d", len(monthlyStats)) - } - - if len(monthlyStats) > 0 { - if monthlyStats[0].Month != "2023-12" { - t.Errorf("Expected first month to be 2023-12, got %s", monthlyStats[0].Month) - } - if monthlyStats[0].Total != 1700 { - t.Errorf("Expected first total to be 1700, got %d", monthlyStats[0].Total) - } - } + db := &database.MockSqlite{} + container := &app.Container{Db: db} + visits := Visits{Container: container} + + // Test with mock data + db.Rows = &database.MockVisitStats_MonthlyRows{} + + monthlyStats := visits.GetMonthlyStats() + + if len(monthlyStats) != 2 { + t.Errorf("Expected 2 monthly stats, got %d", len(monthlyStats)) + } + + if len(monthlyStats) > 0 { + if monthlyStats[0].Month != "2023-12" { + t.Errorf("Expected first month to be 2023-12, got %s", monthlyStats[0].Month) + } + if monthlyStats[0].Total != 1700 { + t.Errorf("Expected first total to be 1700, got %d", monthlyStats[0].Total) + } + } } func TestDailyVisit_GetFriendlyDate(t *testing.T) { - visit := DailyVisit{Date: "2023-12-25"} - - friendly := visit.GetFriendlyDate() - expected := "Monday December 25, 2023" - - if friendly != expected { - t.Errorf("Expected friendly date to be %s, got %s", expected, friendly) - } - - // Test with invalid date - invalidVisit := DailyVisit{Date: "invalid-date"} - if invalidVisit.GetFriendlyDate() != "invalid-date" { - t.Error("Expected invalid date to return original string") - } + visit := DailyVisit{Date: "2023-12-25"} + + friendly := visit.GetFriendlyDate() + expected := "Monday December 25, 2023" + + if friendly != expected { + t.Errorf("Expected friendly date to be %s, got %s", expected, friendly) + } + + // Test with invalid date + invalidVisit := DailyVisit{Date: "invalid-date"} + if invalidVisit.GetFriendlyDate() != "invalid-date" { + t.Error("Expected invalid date to return original string") + } } func TestMonthlyVisit_GetFriendlyMonth(t *testing.T) { - visit := MonthlyVisit{Month: "2023-12"} - - friendly := visit.GetFriendlyMonth() - expected := "December 2023" - - if friendly != expected { - t.Errorf("Expected friendly month to be %s, got %s", expected, friendly) - } - - // Test with invalid month - invalidVisit := MonthlyVisit{Month: "invalid-month"} - if invalidVisit.GetFriendlyMonth() != "invalid-month" { - t.Error("Expected invalid month to return original string") - } + visit := MonthlyVisit{Month: "2023-12"} + + friendly := visit.GetFriendlyMonth() + expected := "December 2023" + + if friendly != expected { + t.Errorf("Expected friendly month to be %s, got %s", expected, friendly) + } + + // Test with invalid month + invalidVisit := MonthlyVisit{Month: "invalid-month"} + if invalidVisit.GetFriendlyMonth() != "invalid-month" { + t.Error("Expected invalid month to return original string") + } } diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 98b5298..612c143 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -1,44 +1,44 @@ package router import ( - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/controller/apiv1" - "github.com/jamiefdhurst/journal/internal/app/controller/web" - pkgrouter "github.com/jamiefdhurst/journal/pkg/router" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/controller/apiv1" + "github.com/jamiefdhurst/journal/internal/app/controller/web" + pkgrouter "github.com/jamiefdhurst/journal/pkg/router" ) // NewRouter Define a new router and initialise routes func NewRouter(app *app.Container) *pkgrouter.Router { - rtr := pkgrouter.Router{} - rtr.Container = app - rtr.ErrorController = &web.BadRequest{} - rtr.StaticPaths = []string{ - app.Configuration.ThemePath + "/" + app.Configuration.Theme, - app.Configuration.StaticPath, - "api", - } + rtr := pkgrouter.Router{} + rtr.Container = app + rtr.ErrorController = &web.BadRequest{} + rtr.StaticPaths = []string{ + app.Configuration.ThemePath + "/" + app.Configuration.Theme, + app.Configuration.StaticPath, + "api", + } - // API v1 - rtr.Get("/api/v1/stats", &apiv1.Stats{}) - rtr.Get("/api/v1/post", &apiv1.List{}) - rtr.Put("/api/v1/post", &apiv1.Create{}) - rtr.Get("/api/v1/post/random", &apiv1.Random{}) - rtr.Get("/api/v1/post/[%s]", &apiv1.Single{}) - rtr.Post("/api/v1/post/[%s]", &apiv1.Update{}) + // API v1 + rtr.Get("/api/v1/stats", &apiv1.Stats{}) + rtr.Get("/api/v1/post", &apiv1.List{}) + rtr.Put("/api/v1/post", &apiv1.Create{}) + rtr.Get("/api/v1/post/random", &apiv1.Random{}) + rtr.Get("/api/v1/post/[%s]", &apiv1.Single{}) + rtr.Post("/api/v1/post/[%s]", &apiv1.Update{}) - // Web - rtr.Get("/sitemap.xml", &web.Sitemap{}) - rtr.Get("/stats", &web.Stats{}) - rtr.Get("/new", &web.New{}) - rtr.Post("/new", &web.New{}) - rtr.Get("/random", &web.Random{}) - rtr.Get("/calendar/[%s]/[%s]", &web.Calendar{}) - rtr.Get("/calendar/[%s]", &web.Calendar{}) - rtr.Get("/calendar", &web.Calendar{}) - rtr.Get("/[%s]/edit", &web.Edit{}) - rtr.Post("/[%s]/edit", &web.Edit{}) - rtr.Get("/[%s]", &web.View{}) - rtr.Get("/", &web.Index{}) + // Web + rtr.Get("/sitemap.xml", &web.Sitemap{}) + rtr.Get("/stats", &web.Stats{}) + rtr.Get("/new", &web.New{}) + rtr.Post("/new", &web.New{}) + rtr.Get("/random", &web.Random{}) + rtr.Get("/calendar/[%s]/[%s]", &web.Calendar{}) + rtr.Get("/calendar/[%s]", &web.Calendar{}) + rtr.Get("/calendar", &web.Calendar{}) + rtr.Get("/[%s]/edit", &web.Edit{}) + rtr.Post("/[%s]/edit", &web.Edit{}) + rtr.Get("/[%s]", &web.View{}) + rtr.Get("/", &web.Index{}) - return &rtr + return &rtr } diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index a0085f1..06fc58b 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -1,106 +1,106 @@ package controller import ( - "net/http" + "net/http" - internalApp "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/pkg/session" + internalApp "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/pkg/session" ) // Controller Main interface for controllers type Controller interface { - Init(app interface{}, params []string, request *http.Request) - Run(response http.ResponseWriter, request *http.Request) - Container() interface{} - Host() string - Params() []string - SaveSession(w http.ResponseWriter) - Session() *session.Session + Init(app interface{}, params []string, request *http.Request) + Run(response http.ResponseWriter, request *http.Request) + Container() interface{} + Host() string + Params() []string + SaveSession(w http.ResponseWriter) + Session() *session.Session } // Super Super-struct for all controllers. type Super struct { - Controller - container interface{} - disableTracking bool - host string - params []string - session *session.Session - sessionStore session.Store + Controller + container interface{} + disableTracking bool + host string + params []string + session *session.Session + sessionStore session.Store } // Init Initialise the controller func (c *Super) Init(app interface{}, params []string, request *http.Request) { - c.container = app - c.host = request.Host - c.params = params - - appContainer, ok := app.(*internalApp.Container) - if ok && appContainer != nil { - store, err := session.NewDefaultStore(appContainer.Configuration.SessionKey, session.CookieConfig{ - Name: appContainer.Configuration.SessionName, - Domain: appContainer.Configuration.CookieDomain, - MaxAge: appContainer.Configuration.CookieMaxAge, - Secure: appContainer.Configuration.CookieSecure, - HTTPOnly: appContainer.Configuration.CookieHTTPOnly, - }) - if err == nil { - c.sessionStore = store - } - } - - if c.sessionStore != nil { - c.session, _ = c.sessionStore.Get(request) - } else { - c.session = session.NewSession() - } - - c.trackVisit(request) + c.container = app + c.host = request.Host + c.params = params + + appContainer, ok := app.(*internalApp.Container) + if ok && appContainer != nil { + store, err := session.NewDefaultStore(appContainer.Configuration.SessionKey, session.CookieConfig{ + Name: appContainer.Configuration.SessionName, + Domain: appContainer.Configuration.CookieDomain, + MaxAge: appContainer.Configuration.CookieMaxAge, + Secure: appContainer.Configuration.CookieSecure, + HTTPOnly: appContainer.Configuration.CookieHTTPOnly, + }) + if err == nil { + c.sessionStore = store + } + } + + if c.sessionStore != nil { + c.session, _ = c.sessionStore.Get(request) + } else { + c.session = session.NewSession() + } + + c.trackVisit(request) } func (c *Super) Container() interface{} { - return c.container + return c.container } func (c *Super) DisableTracking() { - c.disableTracking = true + c.disableTracking = true } func (c *Super) Host() string { - return c.host + return c.host } func (c *Super) Params() []string { - return c.params + return c.params } // SaveSession saves the session with the current response func (c *Super) SaveSession(w http.ResponseWriter) { - if c.sessionStore != nil { - c.sessionStore.Save(w) - } + if c.sessionStore != nil { + c.sessionStore.Save(w) + } } // Session gets the private session value func (c *Super) Session() *session.Session { - return c.session + return c.session } func (c *Super) trackVisit(request *http.Request) { - if c.disableTracking { - return - } + if c.disableTracking { + return + } - if c.container == nil || request == nil || request.URL == nil { - return - } + if c.container == nil || request == nil || request.URL == nil { + return + } - appContainer, ok := c.container.(*internalApp.Container) - if !ok || appContainer.Db == nil { - return - } + appContainer, ok := c.container.(*internalApp.Container) + if !ok || appContainer.Db == nil { + return + } - visits := model.Visits{Container: appContainer} - visits.RecordVisit(request.URL.Path) + visits := model.Visits{Container: appContainer} + visits.RecordVisit(request.URL.Path) } diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go index 3a24069..164a062 100644 --- a/pkg/controller/controller_test.go +++ b/pkg/controller/controller_test.go @@ -1,62 +1,62 @@ package controller import ( - "net/http" - "strings" - "testing" + "net/http" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app" ) type BlankInterface struct{} func TestInit(t *testing.T) { - t.Run("Init with blank interface", func(t *testing.T) { - container := BlankInterface{} - params := []string{ - "param1", "param2", "param3", "param4", - } - controller := Super{} - request, _ := http.NewRequest("GET", "/", strings.NewReader("")) - request.Host = "foobar.com" - controller.Init(container, params, request) - if controller.Container() != container || controller.Params()[2] != "param3" || controller.Host() != "foobar.com" { - t.Error("Expected values were not passed into struct") - } - }) + t.Run("Init with blank interface", func(t *testing.T) { + container := BlankInterface{} + params := []string{ + "param1", "param2", "param3", "param4", + } + controller := Super{} + request, _ := http.NewRequest("GET", "/", strings.NewReader("")) + request.Host = "foobar.com" + controller.Init(container, params, request) + if controller.Container() != container || controller.Params()[2] != "param3" || controller.Host() != "foobar.com" { + t.Error("Expected values were not passed into struct") + } + }) - t.Run("Init with app container and session config", func(t *testing.T) { - container := &app.Container{ - Configuration: app.Configuration{ - SessionKey: "12345678901234567890123456789012", - SessionName: "test-session", - CookieDomain: "example.com", - CookieMaxAge: 3600, - CookieSecure: true, - CookieHTTPOnly: true, - }, - } - params := []string{"param1", "param2"} - controller := Super{} - request, _ := http.NewRequest("GET", "/", strings.NewReader("")) - request.Host = "test.com" + t.Run("Init with app container and session config", func(t *testing.T) { + container := &app.Container{ + Configuration: app.Configuration{ + SessionKey: "12345678901234567890123456789012", + SessionName: "test-session", + CookieDomain: "example.com", + CookieMaxAge: 3600, + CookieSecure: true, + CookieHTTPOnly: true, + }, + } + params := []string{"param1", "param2"} + controller := Super{} + request, _ := http.NewRequest("GET", "/", strings.NewReader("")) + request.Host = "test.com" - controller.Init(container, params, request) + controller.Init(container, params, request) - if controller.Container() != container { - t.Error("Expected container to be set") - } - if controller.Host() != "test.com" { - t.Error("Expected host to be set") - } - if len(controller.Params()) != 2 { - t.Error("Expected params to be set") - } - if controller.sessionStore == nil { - t.Error("Expected session store to be initialized") - } - if controller.session == nil { - t.Error("Expected session to be initialized") - } - }) + if controller.Container() != container { + t.Error("Expected container to be set") + } + if controller.Host() != "test.com" { + t.Error("Expected host to be set") + } + if len(controller.Params()) != 2 { + t.Error("Expected params to be set") + } + if controller.sessionStore == nil { + t.Error("Expected session store to be initialized") + } + if controller.session == nil { + t.Error("Expected session to be initialized") + } + }) } diff --git a/pkg/database/database.go b/pkg/database/database.go index fecd8e0..e710b65 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -1,52 +1,52 @@ package database import ( - "database/sql" - "os" + "database/sql" + "os" - "github.com/jamiefdhurst/journal/pkg/database/rows" - _ "github.com/ncruces/go-sqlite3/driver" // SQLite 3 driver - _ "github.com/ncruces/go-sqlite3/embed" // SQLite 3 embeddings + "github.com/jamiefdhurst/journal/pkg/database/rows" + _ "github.com/ncruces/go-sqlite3/driver" // SQLite 3 driver + _ "github.com/ncruces/go-sqlite3/embed" // SQLite 3 embeddings ) // Database Define a common interface for all database drivers type Database interface { - Close() - Connect(dbFile string) error - Exec(sql string, args ...interface{}) (sql.Result, error) - Query(sql string, args ...interface{}) (rows.Rows, error) + Close() + Connect(dbFile string) error + Exec(sql string, args ...interface{}) (sql.Result, error) + Query(sql string, args ...interface{}) (rows.Rows, error) } // Sqlite Handle an Sqlite connection type Sqlite struct { - Database - db *sql.DB + Database + db *sql.DB } // Close Close open database func (s *Sqlite) Close() { - s.db.Close() + s.db.Close() } // Connect Connect/open the database func (s *Sqlite) Connect(dbFile string) error { - if _, err := os.Stat(dbFile); err != nil { - file, err := os.Create(dbFile) - if err != nil { - return err - } - file.Close() - } - s.db, _ = sql.Open("sqlite3", dbFile) - return s.db.Ping() + if _, err := os.Stat(dbFile); err != nil { + file, err := os.Create(dbFile) + if err != nil { + return err + } + file.Close() + } + s.db, _ = sql.Open("sqlite3", dbFile) + return s.db.Ping() } // Exec Execute a query on the database, returning a simple result func (s *Sqlite) Exec(sql string, args ...interface{}) (sql.Result, error) { - return s.db.Exec(sql, args...) + return s.db.Exec(sql, args...) } // Query Query the database func (s *Sqlite) Query(sql string, args ...interface{}) (rows.Rows, error) { - return s.db.Query(sql, args...) + return s.db.Query(sql, args...) } diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go index 42daa16..137bd27 100644 --- a/pkg/database/database_test.go +++ b/pkg/database/database_test.go @@ -1,50 +1,50 @@ package database import ( - "testing" + "testing" ) func TestSqliteClose(t *testing.T) { - sqlite := &Sqlite{} - _ = sqlite.Connect("../../test/data/test.db") - sqlite.Close() + sqlite := &Sqlite{} + _ = sqlite.Connect("../../test/data/test.db") + sqlite.Close() } func TestSqliteConnect(t *testing.T) { - sqlite := &Sqlite{} - err := sqlite.Connect("../../test/data/test.db") - if err != nil { - t.Errorf("Expected database to have been connected and no error to have been returned, got %s", err) - } + sqlite := &Sqlite{} + err := sqlite.Connect("../../test/data/test.db") + if err != nil { + t.Errorf("Expected database to have been connected and no error to have been returned, got %s", err) + } } func TestSqliteExec(t *testing.T) { - sqlite := &Sqlite{} - _ = sqlite.Connect("../../test/data/test.db") - result, err := sqlite.Exec("SELECT 1") - rows, _ := result.RowsAffected() - if err != nil || rows > 0 { - t.Errorf("Expected query to have been executed and no rows to have been affected") - } + sqlite := &Sqlite{} + _ = sqlite.Connect("../../test/data/test.db") + result, err := sqlite.Exec("SELECT 1") + rows, _ := result.RowsAffected() + if err != nil || rows > 0 { + t.Errorf("Expected query to have been executed and no rows to have been affected") + } } func TestSqliteQuery(t *testing.T) { - sqlite := &Sqlite{} - _ = sqlite.Connect("../../test/data/test.db") - rows, err := sqlite.Query("SELECT 1 AS example") - if err != nil { - t.Errorf("Expected query to have been executed") - } - columns, _ := rows.Columns() - if len(columns) != 1 || columns[0] != "example" { - t.Errorf("Expected column of 'example' to have been returned") - } - var test int - for rows.Next() { - rows.Scan(&test) - if test != 1 { - t.Errorf("Expected row with value of '1' to have been returned") - } - } - rows.Close() + sqlite := &Sqlite{} + _ = sqlite.Connect("../../test/data/test.db") + rows, err := sqlite.Query("SELECT 1 AS example") + if err != nil { + t.Errorf("Expected query to have been executed") + } + columns, _ := rows.Columns() + if len(columns) != 1 || columns[0] != "example" { + t.Errorf("Expected column of 'example' to have been returned") + } + var test int + for rows.Next() { + rows.Scan(&test) + if test != 1 { + t.Errorf("Expected row with value of '1' to have been returned") + } + } + rows.Close() } diff --git a/pkg/database/pagination.go b/pkg/database/pagination.go index 18b378f..aa97ea3 100644 --- a/pkg/database/pagination.go +++ b/pkg/database/pagination.go @@ -1,81 +1,81 @@ package database import ( - "math" - "strconv" + "math" + "strconv" ) const PAGINATION_MAX_PAGES = 7 // PaginationDisplay describes the information needed to display a pagination component type PaginationDisplay struct { - ShowLeft bool - ShowRight bool - CurrentPage int - FirstPage int - LastPage int - TotalPages int + ShowLeft bool + ShowRight bool + CurrentPage int + FirstPage int + LastPage int + TotalPages int } // PaginationInformation is used to return information from a pagination query type PaginationInformation struct { - Page int `json:"current_page"` - TotalPages int `json:"total_pages"` - ResultsPerPage int `json:"posts_per_page"` - TotalResults int `json:"total_posts"` + Page int `json:"current_page"` + TotalPages int `json:"total_pages"` + ResultsPerPage int `json:"posts_per_page"` + TotalResults int `json:"total_posts"` } // PaginationLinks supports previous and next links for JSON results type PaginationLinks struct { - Previous string `json:"previous,omitempty"` - Next string `json:"next,omitempty"` + Previous string `json:"previous,omitempty"` + Next string `json:"next,omitempty"` } // PaginationQuery accepts current page and results per page to generate a query type PaginationQuery struct { - Page int - ResultsPerPage int + Page int + ResultsPerPage int } // PaginationResult is used to return pagination details from a count query type PaginationResult struct { - TotalResults int `json:"total"` + TotalResults int `json:"total"` } func DisplayPagination(info PaginationInformation) PaginationDisplay { - display := PaginationDisplay{false, false, info.Page, 1, info.TotalPages, info.TotalPages} - if info.TotalPages <= PAGINATION_MAX_PAGES { - return display - } - half := int(math.Floor(PAGINATION_MAX_PAGES / 2)) - if info.Page-half > 1 { - display.ShowLeft = true - display.FirstPage = info.Page - half - if info.TotalPages-half <= info.Page { - display.FirstPage = info.TotalPages - PAGINATION_MAX_PAGES + 1 - } - } - if info.Page+half < info.TotalPages { - display.ShowRight = true - display.LastPage = info.Page + half - if info.Page-half <= 1 { - display.LastPage = PAGINATION_MAX_PAGES - } - } + display := PaginationDisplay{false, false, info.Page, 1, info.TotalPages, info.TotalPages} + if info.TotalPages <= PAGINATION_MAX_PAGES { + return display + } + half := int(math.Floor(PAGINATION_MAX_PAGES / 2)) + if info.Page-half > 1 { + display.ShowLeft = true + display.FirstPage = info.Page - half + if info.TotalPages-half <= info.Page { + display.FirstPage = info.TotalPages - PAGINATION_MAX_PAGES + 1 + } + } + if info.Page+half < info.TotalPages { + display.ShowRight = true + display.LastPage = info.Page + half + if info.Page-half <= 1 { + display.LastPage = PAGINATION_MAX_PAGES + } + } - return display + return display } func LinksPagination(url string, info PaginationInformation) PaginationLinks { - links := PaginationLinks{} - if info.TotalPages == 1 { - return links - } - if info.Page < info.TotalPages { - links.Next = url + "?page=" + strconv.Itoa(info.Page+1) - } - if info.Page > 1 { - links.Previous = url + "?page=" + strconv.Itoa(info.Page-1) - } - return links + links := PaginationLinks{} + if info.TotalPages == 1 { + return links + } + if info.Page < info.TotalPages { + links.Next = url + "?page=" + strconv.Itoa(info.Page+1) + } + if info.Page > 1 { + links.Previous = url + "?page=" + strconv.Itoa(info.Page-1) + } + return links } diff --git a/pkg/database/pagination_test.go b/pkg/database/pagination_test.go index edbca4f..59237d0 100644 --- a/pkg/database/pagination_test.go +++ b/pkg/database/pagination_test.go @@ -1,48 +1,48 @@ package database import ( - "testing" + "testing" ) func TestDisplayPagination(t *testing.T) { - tables := []struct { - input PaginationInformation - output PaginationDisplay - }{ - { - PaginationInformation{1, 1, 20, 1}, - PaginationDisplay{false, false, 1, 1, 1, 1}, - }, - { - PaginationInformation{1, 4, 20, 70}, - PaginationDisplay{false, false, 1, 1, 4, 4}, - }, - { - PaginationInformation{1, 7, 20, 135}, - PaginationDisplay{false, false, 1, 1, 7, 7}, - }, - { - PaginationInformation{1, 9, 20, 175}, - PaginationDisplay{false, true, 1, 1, 7, 9}, - }, - { - PaginationInformation{1, 15, 20, 299}, - PaginationDisplay{false, true, 1, 1, 7, 15}, - }, - { - PaginationInformation{15, 15, 20, 299}, - PaginationDisplay{true, false, 15, 9, 15, 15}, - }, - { - PaginationInformation{7, 15, 20, 299}, - PaginationDisplay{true, true, 7, 4, 10, 15}, - }, - } + tables := []struct { + input PaginationInformation + output PaginationDisplay + }{ + { + PaginationInformation{1, 1, 20, 1}, + PaginationDisplay{false, false, 1, 1, 1, 1}, + }, + { + PaginationInformation{1, 4, 20, 70}, + PaginationDisplay{false, false, 1, 1, 4, 4}, + }, + { + PaginationInformation{1, 7, 20, 135}, + PaginationDisplay{false, false, 1, 1, 7, 7}, + }, + { + PaginationInformation{1, 9, 20, 175}, + PaginationDisplay{false, true, 1, 1, 7, 9}, + }, + { + PaginationInformation{1, 15, 20, 299}, + PaginationDisplay{false, true, 1, 1, 7, 15}, + }, + { + PaginationInformation{15, 15, 20, 299}, + PaginationDisplay{true, false, 15, 9, 15, 15}, + }, + { + PaginationInformation{7, 15, 20, 299}, + PaginationDisplay{true, true, 7, 4, 10, 15}, + }, + } - for _, table := range tables { - actual := DisplayPagination(table.input) - if actual != table.output { - t.Errorf("Expected DisplayPagination() to produce result of '%v', got '%v'", table.output, actual) - } - } + for _, table := range tables { + actual := DisplayPagination(table.input) + if actual != table.output { + t.Errorf("Expected DisplayPagination() to produce result of '%v', got '%v'", table.output, actual) + } + } } diff --git a/pkg/database/rows/rows.go b/pkg/database/rows/rows.go index ad7eb9b..cf6b693 100644 --- a/pkg/database/rows/rows.go +++ b/pkg/database/rows/rows.go @@ -2,8 +2,8 @@ package rows // Rows Define a common interface for a result of rows type Rows interface { - Close() error - Columns() ([]string, error) - Next() bool - Scan(dest ...interface{}) error + Close() error + Columns() ([]string, error) + Next() bool + Scan(dest ...interface{}) error } diff --git a/pkg/env/parser.go b/pkg/env/parser.go index 8ed3c7e..5d2da3b 100644 --- a/pkg/env/parser.go +++ b/pkg/env/parser.go @@ -1,63 +1,63 @@ package env import ( - "bufio" - "os" - "strings" + "bufio" + "os" + "strings" ) // Parse reads a .env file and returns a map of key-value pairs // It does not modify the actual environment variables func Parse(filepath string) (map[string]string, error) { - result := make(map[string]string) - - file, err := os.Open(filepath) - if err != nil { - // If file doesn't exist, return empty map (not an error) - if os.IsNotExist(err) { - return result, nil - } - return nil, err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - // Skip empty lines and comments - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - // Split on first = sign - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - // Remove quotes if present - value = unquote(value) - - result[key] = value - } - - if err := scanner.Err(); err != nil { - return nil, err - } - - return result, nil + result := make(map[string]string) + + file, err := os.Open(filepath) + if err != nil { + // If file doesn't exist, return empty map (not an error) + if os.IsNotExist(err) { + return result, nil + } + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Split on first = sign + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove quotes if present + value = unquote(value) + + result[key] = value + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return result, nil } // unquote removes surrounding quotes from a string func unquote(s string) string { - if len(s) >= 2 { - if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { - return s[1 : len(s)-1] - } - } - return s + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s } diff --git a/pkg/env/parser_test.go b/pkg/env/parser_test.go index 1185acd..eaa9388 100644 --- a/pkg/env/parser_test.go +++ b/pkg/env/parser_test.go @@ -1,177 +1,177 @@ package env import ( - "os" - "path/filepath" - "testing" + "os" + "path/filepath" + "testing" ) func TestParse(t *testing.T) { - tests := []struct { - name string - content string - expected map[string]string - }{ - { - name: "basic key-value pairs", - content: `KEY1=value1 + tests := []struct { + name string + content string + expected map[string]string + }{ + { + name: "basic key-value pairs", + content: `KEY1=value1 KEY2=value2 KEY3=value3`, - expected: map[string]string{ - "KEY1": "value1", - "KEY2": "value2", - "KEY3": "value3", - }, - }, - { - name: "with comments", - content: `# This is a comment + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + "KEY3": "value3", + }, + }, + { + name: "with comments", + content: `# This is a comment KEY1=value1 # Another comment KEY2=value2`, - expected: map[string]string{ - "KEY1": "value1", - "KEY2": "value2", - }, - }, - { - name: "with empty lines", - content: `KEY1=value1 + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + }, + { + name: "with empty lines", + content: `KEY1=value1 KEY2=value2 `, - expected: map[string]string{ - "KEY1": "value1", - "KEY2": "value2", - }, - }, - { - name: "with quoted values", - content: `KEY1="value with spaces" + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + }, + { + name: "with quoted values", + content: `KEY1="value with spaces" KEY2='single quoted value' KEY3=unquoted`, - expected: map[string]string{ - "KEY1": "value with spaces", - "KEY2": "single quoted value", - "KEY3": "unquoted", - }, - }, - { - name: "with spaces around equals", - content: `KEY1 = value1 + expected: map[string]string{ + "KEY1": "value with spaces", + "KEY2": "single quoted value", + "KEY3": "unquoted", + }, + }, + { + name: "with spaces around equals", + content: `KEY1 = value1 KEY2= value2 KEY3 =value3`, - expected: map[string]string{ - "KEY1": "value1", - "KEY2": "value2", - "KEY3": "value3", - }, - }, - { - name: "with equals in value", - content: `KEY1=value=with=equals + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + "KEY3": "value3", + }, + }, + { + name: "with equals in value", + content: `KEY1=value=with=equals KEY2=http://example.com?param=value`, - expected: map[string]string{ - "KEY1": "value=with=equals", - "KEY2": "http://example.com?param=value", - }, - }, - { - name: "malformed lines are skipped", - content: `KEY1=value1 + expected: map[string]string{ + "KEY1": "value=with=equals", + "KEY2": "http://example.com?param=value", + }, + }, + { + name: "malformed lines are skipped", + content: `KEY1=value1 INVALID_LINE_NO_EQUALS KEY2=value2`, - expected: map[string]string{ - "KEY1": "value1", - "KEY2": "value2", - }, - }, - { - name: "empty file", - content: "", - expected: map[string]string{}, - }, - { - name: "only comments and empty lines", - content: `# Comment 1 + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + }, + { + name: "empty file", + content: "", + expected: map[string]string{}, + }, + { + name: "only comments and empty lines", + content: `# Comment 1 # Comment 2 # Comment 3`, - expected: map[string]string{}, - }, - } + expected: map[string]string{}, + }, + } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a temporary .env file - tmpDir := t.TempDir() - envFile := filepath.Join(tmpDir, ".env") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary .env file + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".env") - if err := os.WriteFile(envFile, []byte(tt.content), 0644); err != nil { - t.Fatalf("Failed to create temp file: %v", err) - } + if err := os.WriteFile(envFile, []byte(tt.content), 0644); err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } - // Parse the file - result, err := Parse(envFile) - if err != nil { - t.Fatalf("Parse() error = %v", err) - } + // Parse the file + result, err := Parse(envFile) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } - // Check the results - if len(result) != len(tt.expected) { - t.Errorf("Expected %d entries, got %d", len(tt.expected), len(result)) - } + // Check the results + if len(result) != len(tt.expected) { + t.Errorf("Expected %d entries, got %d", len(tt.expected), len(result)) + } - for key, expectedValue := range tt.expected { - if actualValue, ok := result[key]; !ok { - t.Errorf("Missing key %q", key) - } else if actualValue != expectedValue { - t.Errorf("For key %q: expected %q, got %q", key, expectedValue, actualValue) - } - } + for key, expectedValue := range tt.expected { + if actualValue, ok := result[key]; !ok { + t.Errorf("Missing key %q", key) + } else if actualValue != expectedValue { + t.Errorf("For key %q: expected %q, got %q", key, expectedValue, actualValue) + } + } - for key := range result { - if _, ok := tt.expected[key]; !ok { - t.Errorf("Unexpected key %q with value %q", key, result[key]) - } - } - }) - } + for key := range result { + if _, ok := tt.expected[key]; !ok { + t.Errorf("Unexpected key %q with value %q", key, result[key]) + } + } + }) + } } func TestParseNonExistentFile(t *testing.T) { - // Parsing a non-existent file should return an empty map, not an error - result, err := Parse("/nonexistent/path/.env") - if err != nil { - t.Errorf("Parse() should not error on non-existent file, got: %v", err) - } - if len(result) != 0 { - t.Errorf("Expected empty map, got %d entries", len(result)) - } + // Parsing a non-existent file should return an empty map, not an error + result, err := Parse("/nonexistent/path/.env") + if err != nil { + t.Errorf("Parse() should not error on non-existent file, got: %v", err) + } + if len(result) != 0 { + t.Errorf("Expected empty map, got %d entries", len(result)) + } } func TestUnquote(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {`"double quoted"`, "double quoted"}, - {`'single quoted'`, "single quoted"}, - {`unquoted`, "unquoted"}, - {`"`, `"`}, - {`''`, ``}, - {`""`, ``}, - {`"mismatched'`, `"mismatched'`}, - {`'mismatched"`, `'mismatched"`}, - } + tests := []struct { + input string + expected string + }{ + {`"double quoted"`, "double quoted"}, + {`'single quoted'`, "single quoted"}, + {`unquoted`, "unquoted"}, + {`"`, `"`}, + {`''`, ``}, + {`""`, ``}, + {`"mismatched'`, `"mismatched'`}, + {`'mismatched"`, `'mismatched"`}, + } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := unquote(tt.input) - if result != tt.expected { - t.Errorf("unquote(%q) = %q, expected %q", tt.input, result, tt.expected) - } - }) - } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := unquote(tt.input) + if result != tt.expected { + t.Errorf("unquote(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } } diff --git a/pkg/markdown/markdown.go b/pkg/markdown/markdown.go index bcb6722..8089278 100644 --- a/pkg/markdown/markdown.go +++ b/pkg/markdown/markdown.go @@ -1,12 +1,12 @@ package markdown import ( - "regexp" - "strings" + "regexp" + "strings" - "github.com/gomarkdown/markdown" - "github.com/gomarkdown/markdown/html" - "github.com/gomarkdown/markdown/parser" + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" ) // Markdown implements the MarkdownProcessor interface @@ -14,65 +14,65 @@ type Markdown struct{} // ToHTML converts markdown text to HTML func (m *Markdown) ToHTML(input string) string { - // Create markdown parser with limited extensions - // Only enable bullet lists, images, links, emphasis (strong and italics) - extensions := parser.NoIntraEmphasis | - parser.FencedCode | - parser.Tables | - parser.Autolink | - parser.Strikethrough | - parser.SpaceHeadings + // Create markdown parser with limited extensions + // Only enable bullet lists, images, links, emphasis (strong and italics) + extensions := parser.NoIntraEmphasis | + parser.FencedCode | + parser.Tables | + parser.Autolink | + parser.Strikethrough | + parser.SpaceHeadings - p := parser.NewWithExtensions(extensions) + p := parser.NewWithExtensions(extensions) - // Parse the markdown text - nodes := p.Parse([]byte(input)) + // Parse the markdown text + nodes := p.Parse([]byte(input)) - // Create HTML renderer with specific rendering flags - htmlFlags := html.CommonFlags - // Filter out heading tags by using a custom renderer if needed + // Create HTML renderer with specific rendering flags + htmlFlags := html.CommonFlags + // Filter out heading tags by using a custom renderer if needed - opts := html.RendererOptions{Flags: htmlFlags} - renderer := html.NewRenderer(opts) + opts := html.RendererOptions{Flags: htmlFlags} + renderer := html.NewRenderer(opts) - return string(markdown.Render(nodes, renderer)) + return string(markdown.Render(nodes, renderer)) } // FromHTML converts HTML text to Markdown (best effort for migration) func (m *Markdown) FromHTML(input string) string { - // Basic HTML to Markdown conversion for the limited set of tags we support - // Replace paragraph tags - output := strings.ReplaceAll(input, "

", "") - output = strings.ReplaceAll(output, "

", "\n\n") - - // Replace strong/bold tags - output = strings.ReplaceAll(output, "", "**") - output = strings.ReplaceAll(output, "", "**") - output = strings.ReplaceAll(output, "", "**") - output = strings.ReplaceAll(output, "", "**") - - // Replace italic tags - output = strings.ReplaceAll(output, "", "*") - output = strings.ReplaceAll(output, "", "*") - output = strings.ReplaceAll(output, "", "*") - output = strings.ReplaceAll(output, "", "*") - - // Replace unordered list - output = strings.ReplaceAll(output, "
    ", "") - output = strings.ReplaceAll(output, "
", "\n") - output = strings.ReplaceAll(output, "
  • ", "* ") - output = strings.ReplaceAll(output, "
  • ", "\n") - - // Replace links - linkRegex := regexp.MustCompile(`]*>([^<]+)`) - output = linkRegex.ReplaceAllString(output, "[$2]($1)") - - // Replace images - imgRegex := regexp.MustCompile(`]*alt="([^"]*)"[^>]*>`) - output = imgRegex.ReplaceAllString(output, "![$2]($1)") - - // Clean up extra whitespace - output = strings.TrimSpace(output) - - return output + // Basic HTML to Markdown conversion for the limited set of tags we support + // Replace paragraph tags + output := strings.ReplaceAll(input, "

    ", "") + output = strings.ReplaceAll(output, "

    ", "\n\n") + + // Replace strong/bold tags + output = strings.ReplaceAll(output, "", "**") + output = strings.ReplaceAll(output, "", "**") + output = strings.ReplaceAll(output, "", "**") + output = strings.ReplaceAll(output, "", "**") + + // Replace italic tags + output = strings.ReplaceAll(output, "", "*") + output = strings.ReplaceAll(output, "", "*") + output = strings.ReplaceAll(output, "", "*") + output = strings.ReplaceAll(output, "", "*") + + // Replace unordered list + output = strings.ReplaceAll(output, "
      ", "") + output = strings.ReplaceAll(output, "
    ", "\n") + output = strings.ReplaceAll(output, "
  • ", "* ") + output = strings.ReplaceAll(output, "
  • ", "\n") + + // Replace links + linkRegex := regexp.MustCompile(`]*>([^<]+)`) + output = linkRegex.ReplaceAllString(output, "[$2]($1)") + + // Replace images + imgRegex := regexp.MustCompile(`]*alt="([^"]*)"[^>]*>`) + output = imgRegex.ReplaceAllString(output, "![$2]($1)") + + // Clean up extra whitespace + output = strings.TrimSpace(output) + + return output } \ No newline at end of file diff --git a/pkg/markdown/markdown_test.go b/pkg/markdown/markdown_test.go index 636ea53..b1681c0 100644 --- a/pkg/markdown/markdown_test.go +++ b/pkg/markdown/markdown_test.go @@ -1,110 +1,110 @@ package markdown import ( - "strings" - "testing" + "strings" + "testing" ) func TestToHTML(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "Convert plain text", - input: "This is plain text", - expected: "

    This is plain text

    \n", - }, - { - name: "Convert bold text", - input: "This is **bold** text", - expected: "

    This is bold text

    \n", - }, - { - name: "Convert italic text", - input: "This is *italic* text", - expected: "

    This is italic text

    \n", - }, - { - name: "Convert bullet list", - input: "* Item 1\n* Item 2\n* Item 3", - expected: "
      \n
    • Item 1
    • \n
    • Item 2
    • \n
    • Item 3
    • \n
    \n", - }, - { - name: "Convert link", - input: "[Link text](https://example.com)", - expected: "

    Link text

    \n", - }, - { - name: "Convert image", - input: "![Alt text](https://example.com/image.jpg)", - expected: "

    \"Alt

    \n", - }, - } + tests := []struct { + name string + input string + expected string + }{ + { + name: "Convert plain text", + input: "This is plain text", + expected: "

    This is plain text

    \n", + }, + { + name: "Convert bold text", + input: "This is **bold** text", + expected: "

    This is bold text

    \n", + }, + { + name: "Convert italic text", + input: "This is *italic* text", + expected: "

    This is italic text

    \n", + }, + { + name: "Convert bullet list", + input: "* Item 1\n* Item 2\n* Item 3", + expected: "
      \n
    • Item 1
    • \n
    • Item 2
    • \n
    • Item 3
    • \n
    \n", + }, + { + name: "Convert link", + input: "[Link text](https://example.com)", + expected: "

    Link text

    \n", + }, + { + name: "Convert image", + input: "![Alt text](https://example.com/image.jpg)", + expected: "

    \"Alt

    \n", + }, + } - markdown := &Markdown{} - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := markdown.ToHTML(test.input) - if result != test.expected { - t.Errorf("Expected %q, got %q", test.expected, result) - } - }) - } + markdown := &Markdown{} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := markdown.ToHTML(test.input) + if result != test.expected { + t.Errorf("Expected %q, got %q", test.expected, result) + } + }) + } } func TestFromHTML(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "Convert paragraph", - input: "

    This is a paragraph

    ", - expected: "This is a paragraph", - }, - { - name: "Convert multiple paragraphs", - input: "

    Paragraph 1

    Paragraph 2

    ", - expected: "Paragraph 1\n\nParagraph 2", - }, - { - name: "Convert bold", - input: "

    This is bold text

    ", - expected: "This is **bold** text", - }, - { - name: "Convert italic", - input: "

    This is italic text

    ", - expected: "This is *italic* text", - }, - { - name: "Convert list", - input: "
    • Item 1
    • Item 2
    • Item 3
    ", - expected: "* Item 1\n* Item 2\n* Item 3", - }, - { - name: "Convert link", - input: "

    Link text

    ", - expected: "[Link text](https://example.com)", - }, - { - name: "Convert image", - input: "

    \"Alt

    ", - expected: "![Alt text](https://example.com/image.jpg)", - }, - } + tests := []struct { + name string + input string + expected string + }{ + { + name: "Convert paragraph", + input: "

    This is a paragraph

    ", + expected: "This is a paragraph", + }, + { + name: "Convert multiple paragraphs", + input: "

    Paragraph 1

    Paragraph 2

    ", + expected: "Paragraph 1\n\nParagraph 2", + }, + { + name: "Convert bold", + input: "

    This is bold text

    ", + expected: "This is **bold** text", + }, + { + name: "Convert italic", + input: "

    This is italic text

    ", + expected: "This is *italic* text", + }, + { + name: "Convert list", + input: "
    • Item 1
    • Item 2
    • Item 3
    ", + expected: "* Item 1\n* Item 2\n* Item 3", + }, + { + name: "Convert link", + input: "

    Link text

    ", + expected: "[Link text](https://example.com)", + }, + { + name: "Convert image", + input: "

    \"Alt

    ", + expected: "![Alt text](https://example.com/image.jpg)", + }, + } - markdown := &Markdown{} - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := markdown.FromHTML(test.input) - // Trim to handle any extra whitespace differences - if strings.TrimSpace(result) != strings.TrimSpace(test.expected) { - t.Errorf("Expected %q, got %q", test.expected, result) - } - }) - } + markdown := &Markdown{} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := markdown.FromHTML(test.input) + // Trim to handle any extra whitespace differences + if strings.TrimSpace(result) != strings.TrimSpace(test.expected) { + t.Errorf("Expected %q, got %q", test.expected, result) + } + }) + } } \ No newline at end of file diff --git a/pkg/router/router.go b/pkg/router/router.go index 6b9f85b..af78830 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -1,118 +1,118 @@ package router import ( - "log" - "net/http" - "os" - "regexp" - "strings" + "log" + "net/http" + "os" + "regexp" + "strings" - "github.com/jamiefdhurst/journal/pkg/controller" + "github.com/jamiefdhurst/journal/pkg/controller" ) // Server Common interface for HTTP type Server interface { - ListenAndServe() error - ListenAndServeTLS(string, string) error + ListenAndServe() error + ListenAndServeTLS(string, string) error } // Route A route contains a method (GET), URI, and a controller type Route struct { - method string - regexURI string - controller controller.Controller + method string + regexURI string + controller controller.Controller } // Router A router contains routes and links back to the application and implements the ServeHTTP interface type Router struct { - isHTTPS bool `default:"false"` - Container interface{} - Routes []Route - StaticPaths []string - ErrorController controller.Controller + isHTTPS bool `default:"false"` + Container interface{} + Routes []Route + StaticPaths []string + ErrorController controller.Controller } func (r Router) convertSimpleURIToRegex(uri string) string { - uri = strings.Replace(uri, "/", "\\/", -1) + uri = strings.Replace(uri, "/", "\\/", -1) - // Match slugs - uri = strings.Replace(uri, "[%s]", "([\\w\\-]+)", -1) + // Match slugs + uri = strings.Replace(uri, "[%s]", "([\\w\\-]+)", -1) - // Match IDs - uri = strings.Replace(uri, "[%d]", "(\\d+)", -1) + // Match IDs + uri = strings.Replace(uri, "[%d]", "(\\d+)", -1) - // Match anything - uri = strings.Replace(uri, "[%a]", "(.+?)", -1) + // Match anything + uri = strings.Replace(uri, "[%a]", "(.+?)", -1) - return "^" + uri + "$" + return "^" + uri + "$" } // Get Create and add a new route into the router to handle a GET request func (r *Router) Get(uri string, controller controller.Controller) { - r.Routes = append(r.Routes, Route{"GET", r.convertSimpleURIToRegex(uri), controller}) + r.Routes = append(r.Routes, Route{"GET", r.convertSimpleURIToRegex(uri), controller}) } // Post Create and add a new route into the router to handle a POST request func (r *Router) Post(uri string, controller controller.Controller) { - r.Routes = append(r.Routes, Route{"POST", r.convertSimpleURIToRegex(uri), controller}) + r.Routes = append(r.Routes, Route{"POST", r.convertSimpleURIToRegex(uri), controller}) } // Put Create and add a new route into the router to handle a PUT request func (r *Router) Put(uri string, controller controller.Controller) { - r.Routes = append(r.Routes, Route{"PUT", r.convertSimpleURIToRegex(uri), controller}) + r.Routes = append(r.Routes, Route{"PUT", r.convertSimpleURIToRegex(uri), controller}) } // ServeHTTP Serve a given HTTP request func (r *Router) ServeHTTP(response http.ResponseWriter, request *http.Request) { - // Debug output into the console - log.Printf("%s: %s", request.Method, request.URL.Path) - - // Security headers - if r.isHTTPS { - response.Header().Add("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") - } - response.Header().Add("Content-Security-Policy", "default-src: 'self'; font-src: 'fonts.googleapis.com'; frame-src: 'none'") - response.Header().Add("X-XSS-Protection", "mode=block") - - // Attempt to serve a file first from available static paths - for _, staticPath := range r.StaticPaths { - if request.URL.Path != "/" { - file := staticPath + request.URL.Path - _, err := os.Stat(file) - if !os.IsNotExist(err) { - response.Header().Add("Cache-Control", "public, max-age=15552000") - http.ServeFile(response, request, file) - return - } - } - } - - // Go through each route and attempt to match - var matchedController controller.Controller = r.ErrorController - var matchedParams []string = []string{} - for _, route := range r.Routes { - matched, _ := regexp.MatchString(route.regexURI, request.URL.Path) - if matched && (request.Method == route.method || (request.Method == "" && route.method == "GET")) { - re := regexp.MustCompile(route.regexURI) - matchedParams = re.FindStringSubmatch(request.URL.Path) - matchedController = route.controller - break - } - } - - matchedController.Init(r.Container, matchedParams, request) - matchedController.Run(response, request) + // Debug output into the console + log.Printf("%s: %s", request.Method, request.URL.Path) + + // Security headers + if r.isHTTPS { + response.Header().Add("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") + } + response.Header().Add("Content-Security-Policy", "default-src: 'self'; font-src: 'fonts.googleapis.com'; frame-src: 'none'") + response.Header().Add("X-XSS-Protection", "mode=block") + + // Attempt to serve a file first from available static paths + for _, staticPath := range r.StaticPaths { + if request.URL.Path != "/" { + file := staticPath + request.URL.Path + _, err := os.Stat(file) + if !os.IsNotExist(err) { + response.Header().Add("Cache-Control", "public, max-age=15552000") + http.ServeFile(response, request, file) + return + } + } + } + + // Go through each route and attempt to match + var matchedController controller.Controller = r.ErrorController + var matchedParams []string = []string{} + for _, route := range r.Routes { + matched, _ := regexp.MatchString(route.regexURI, request.URL.Path) + if matched && (request.Method == route.method || (request.Method == "" && route.method == "GET")) { + re := regexp.MustCompile(route.regexURI) + matchedParams = re.FindStringSubmatch(request.URL.Path) + matchedController = route.controller + break + } + } + + matchedController.Init(r.Container, matchedParams, request) + matchedController.Run(response, request) } // StartAndServe Start the HTTP server and listen for connections func (r *Router) StartAndServe(server Server) error { - r.isHTTPS = false - return server.ListenAndServe() + r.isHTTPS = false + return server.ListenAndServe() } // StartAndServeTls Start the HTTP server and listen for connections with Tls func (r *Router) StartAndServeTLS(server Server, cert string, key string) error { - r.isHTTPS = true - return server.ListenAndServeTLS(cert, key) + r.isHTTPS = true + return server.ListenAndServeTLS(cert, key) } diff --git a/pkg/router/router_test.go b/pkg/router/router_test.go index 3fa6268..0134f81 100644 --- a/pkg/router/router_test.go +++ b/pkg/router/router_test.go @@ -1,195 +1,195 @@ package router import ( - "net/http" - "os" - "path" - "runtime" - "testing" - - "github.com/jamiefdhurst/journal/test/mocks/controller" - mockRouter "github.com/jamiefdhurst/journal/test/mocks/router" + "net/http" + "os" + "path" + "runtime" + "testing" + + "github.com/jamiefdhurst/journal/test/mocks/controller" + mockRouter "github.com/jamiefdhurst/journal/test/mocks/router" ) type BlankContainer struct{} func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "../..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } } func TestGet(t *testing.T) { - ctrl := &controller.MockController{} - router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} - - // Test normal route - router.Get("/testing", ctrl) - if router.Routes[0].controller != ctrl || router.Routes[0].method != "GET" || router.Routes[0].regexURI != "^\\/testing$" { - t.Errorf("GET Route added was not as expected") - } - - // Test param route - router.Get("/[%s]/[%d]/[%a]", ctrl) - if router.Routes[1].regexURI != "^\\/([\\w\\-]+)\\/(\\d+)\\/(.+?)$" { - t.Errorf("GET Route added was not as expected") - } + ctrl := &controller.MockController{} + router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} + + // Test normal route + router.Get("/testing", ctrl) + if router.Routes[0].controller != ctrl || router.Routes[0].method != "GET" || router.Routes[0].regexURI != "^\\/testing$" { + t.Errorf("GET Route added was not as expected") + } + + // Test param route + router.Get("/[%s]/[%d]/[%a]", ctrl) + if router.Routes[1].regexURI != "^\\/([\\w\\-]+)\\/(\\d+)\\/(.+?)$" { + t.Errorf("GET Route added was not as expected") + } } func TestPost(t *testing.T) { - ctrl := &controller.MockController{} - router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} - - // Test normal route - router.Post("/testing", ctrl) - if router.Routes[0].controller != ctrl || router.Routes[0].method != "POST" || router.Routes[0].regexURI != "^\\/testing$" { - t.Errorf("GET Route added was not as expected") - } - - // Test param route - router.Post("/[%s]/[%d]/[%a]", ctrl) - if router.Routes[1].regexURI != "^\\/([\\w\\-]+)\\/(\\d+)\\/(.+?)$" { - t.Errorf("GET Route added was not as expected") - } + ctrl := &controller.MockController{} + router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} + + // Test normal route + router.Post("/testing", ctrl) + if router.Routes[0].controller != ctrl || router.Routes[0].method != "POST" || router.Routes[0].regexURI != "^\\/testing$" { + t.Errorf("GET Route added was not as expected") + } + + // Test param route + router.Post("/[%s]/[%d]/[%a]", ctrl) + if router.Routes[1].regexURI != "^\\/([\\w\\-]+)\\/(\\d+)\\/(.+?)$" { + t.Errorf("GET Route added was not as expected") + } } func TestPut(t *testing.T) { - ctrl := &controller.MockController{} - router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} - - // Test normal route - router.Put("/testing", ctrl) - if router.Routes[0].controller != ctrl || router.Routes[0].method != "PUT" || router.Routes[0].regexURI != "^\\/testing$" { - t.Errorf("GET Route added was not as expected") - } - - // Test param route - router.Put("/[%s]/[%d]/[%a]", ctrl) - if router.Routes[1].regexURI != "^\\/([\\w\\-]+)\\/(\\d+)\\/(.+?)$" { - t.Errorf("GET Route added was not as expected") - } + ctrl := &controller.MockController{} + router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} + + // Test normal route + router.Put("/testing", ctrl) + if router.Routes[0].controller != ctrl || router.Routes[0].method != "PUT" || router.Routes[0].regexURI != "^\\/testing$" { + t.Errorf("GET Route added was not as expected") + } + + // Test param route + router.Put("/[%s]/[%d]/[%a]", ctrl) + if router.Routes[1].regexURI != "^\\/([\\w\\-]+)\\/(\\d+)\\/(.+?)$" { + t.Errorf("GET Route added was not as expected") + } } func TestServeHTTP(t *testing.T) { - errorController := &controller.MockController{} - indexController := &controller.MockController{} - standardController := &controller.MockController{} - paramController := &controller.MockController{} - response := controller.NewMockResponse() - router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: errorController, StaticPaths: []string{"test"}} - router.Get("/standard", standardController) - router.Get("/param/[%s]", paramController) - router.Get("/", indexController) - - // Serve static file - staticRequest, _ := http.NewRequest("GET", "/style.css", nil) - router.ServeHTTP(response, staticRequest) - if errorController.HasRun { - t.Errorf("Expected static file to have been served but error controller was run") - errorController.HasRun = false - } - - // Index - indexRequest, _ := http.NewRequest("GET", "/", nil) - router.ServeHTTP(response, indexRequest) - if !indexController.HasRun || errorController.HasRun { - t.Errorf("Expected index controller to have been served but error controller was run") - errorController.HasRun = false - } - - // Standard route - standardRequest, _ := http.NewRequest("GET", "/standard", nil) - router.ServeHTTP(response, standardRequest) - if !standardController.HasRun || errorController.HasRun { - t.Errorf("Expected standard controller to have been served but error controller was run") - errorController.HasRun = false - } - - // Param route - paramRequest, _ := http.NewRequest("GET", "/param/test1", nil) - router.ServeHTTP(response, paramRequest) - if !paramController.HasRun || errorController.HasRun { - t.Errorf("Expected param controller to have been served but error controller was run") - errorController.HasRun = false - } - - // Not found route - notFoundRequest, _ := http.NewRequest("GET", "/random", nil) - router.ServeHTTP(response, notFoundRequest) - if !errorController.HasRun { - t.Errorf("Expected error controller to have been served") - } + errorController := &controller.MockController{} + indexController := &controller.MockController{} + standardController := &controller.MockController{} + paramController := &controller.MockController{} + response := controller.NewMockResponse() + router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: errorController, StaticPaths: []string{"test"}} + router.Get("/standard", standardController) + router.Get("/param/[%s]", paramController) + router.Get("/", indexController) + + // Serve static file + staticRequest, _ := http.NewRequest("GET", "/style.css", nil) + router.ServeHTTP(response, staticRequest) + if errorController.HasRun { + t.Errorf("Expected static file to have been served but error controller was run") + errorController.HasRun = false + } + + // Index + indexRequest, _ := http.NewRequest("GET", "/", nil) + router.ServeHTTP(response, indexRequest) + if !indexController.HasRun || errorController.HasRun { + t.Errorf("Expected index controller to have been served but error controller was run") + errorController.HasRun = false + } + + // Standard route + standardRequest, _ := http.NewRequest("GET", "/standard", nil) + router.ServeHTTP(response, standardRequest) + if !standardController.HasRun || errorController.HasRun { + t.Errorf("Expected standard controller to have been served but error controller was run") + errorController.HasRun = false + } + + // Param route + paramRequest, _ := http.NewRequest("GET", "/param/test1", nil) + router.ServeHTTP(response, paramRequest) + if !paramController.HasRun || errorController.HasRun { + t.Errorf("Expected param controller to have been served but error controller was run") + errorController.HasRun = false + } + + // Not found route + notFoundRequest, _ := http.NewRequest("GET", "/random", nil) + router.ServeHTTP(response, notFoundRequest) + if !errorController.HasRun { + t.Errorf("Expected error controller to have been served") + } } func TestServeHTTP_HTTPHeaders(t *testing.T) { - ctrl := &controller.MockController{} - router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} - server := &mockRouter.MockServer{} - router.StartAndServe(server) - - response := controller.NewMockResponse() - request, _ := http.NewRequest("GET", "/random", nil) - router.ServeHTTP(response, request) - - csp := response.Headers.Get("Content-Security-Policy") - xss := response.Headers.Get("X-XSS-Protection") - sts := response.Headers.Get("Strict-Transport-Security") - if csp == "" { - t.Error("Expected CSP header to be present") - } - if xss == "" { - t.Error("Expected XSS header to be present") - } - if sts != "" { - t.Error("Expected STS header to not be present") - } + ctrl := &controller.MockController{} + router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} + server := &mockRouter.MockServer{} + router.StartAndServe(server) + + response := controller.NewMockResponse() + request, _ := http.NewRequest("GET", "/random", nil) + router.ServeHTTP(response, request) + + csp := response.Headers.Get("Content-Security-Policy") + xss := response.Headers.Get("X-XSS-Protection") + sts := response.Headers.Get("Strict-Transport-Security") + if csp == "" { + t.Error("Expected CSP header to be present") + } + if xss == "" { + t.Error("Expected XSS header to be present") + } + if sts != "" { + t.Error("Expected STS header to not be present") + } } func TestServeHTTP_HTTPSHeaders(t *testing.T) { - ctrl := &controller.MockController{} - router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} - server := &mockRouter.MockServer{} - router.StartAndServeTLS(server, "test/cert.pem", "test/key.pem") - - response := controller.NewMockResponse() - request, _ := http.NewRequest("GET", "/random", nil) - router.ServeHTTP(response, request) - - csp := response.Headers.Get("Content-Security-Policy") - xss := response.Headers.Get("X-XSS-Protection") - sts := response.Headers.Get("Strict-Transport-Security") - if csp == "" { - t.Error("Expected CSP header to be present") - } - if xss == "" { - t.Error("Expected XSS header to be present") - } - if sts == "" { - t.Error("Expected STS header to be present") - } + ctrl := &controller.MockController{} + router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} + server := &mockRouter.MockServer{} + router.StartAndServeTLS(server, "test/cert.pem", "test/key.pem") + + response := controller.NewMockResponse() + request, _ := http.NewRequest("GET", "/random", nil) + router.ServeHTTP(response, request) + + csp := response.Headers.Get("Content-Security-Policy") + xss := response.Headers.Get("X-XSS-Protection") + sts := response.Headers.Get("Strict-Transport-Security") + if csp == "" { + t.Error("Expected CSP header to be present") + } + if xss == "" { + t.Error("Expected XSS header to be present") + } + if sts == "" { + t.Error("Expected STS header to be present") + } } func TestStartAndServe(t *testing.T) { - ctrl := &controller.MockController{} - router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} - server := &mockRouter.MockServer{} - router.StartAndServe(server) - - if !server.Listening { - t.Errorf("Expected some routes to have been defined but none were found") - } + ctrl := &controller.MockController{} + router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} + server := &mockRouter.MockServer{} + router.StartAndServe(server) + + if !server.Listening { + t.Errorf("Expected some routes to have been defined but none were found") + } } func TestStartAndServeTLS(t *testing.T) { - ctrl := &controller.MockController{} - router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} - server := &mockRouter.MockServer{} - router.StartAndServeTLS(server, "test/cert.pem", "test/key.pem") - - if !server.Listening { - t.Errorf("Expected some routes to have been defined but none were found") - } + ctrl := &controller.MockController{} + router := Router{Container: &BlankContainer{}, Routes: []Route{}, ErrorController: ctrl} + server := &mockRouter.MockServer{} + router.StartAndServeTLS(server, "test/cert.pem", "test/key.pem") + + if !server.Listening { + t.Errorf("Expected some routes to have been defined but none were found") + } } diff --git a/pkg/session/session.go b/pkg/session/session.go index 3510622..d5df666 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -3,49 +3,49 @@ package session const flashKey = "_flash" type Session struct { - ID string - Values map[string]interface{} + ID string + Values map[string]interface{} } func NewSession() *Session { - return &Session{ - Values: make(map[string]interface{}), - } + return &Session{ + Values: make(map[string]interface{}), + } } func (s *Session) GetFlash() []interface{} { - var flashes []interface{} + var flashes []interface{} - if v, ok := s.Values[flashKey]; ok { - delete(s.Values, flashKey) - flashes = v.([]interface{}) - } + if v, ok := s.Values[flashKey]; ok { + delete(s.Values, flashKey) + flashes = v.([]interface{}) + } - return flashes + return flashes } func (s *Session) AddFlash(value interface{}) { - var flashes []interface{} - if v, ok := s.Values[flashKey]; ok { - flashes = v.([]interface{}) - } - s.Values[flashKey] = append(flashes, value) + var flashes []interface{} + if v, ok := s.Values[flashKey]; ok { + flashes = v.([]interface{}) + } + s.Values[flashKey] = append(flashes, value) } // Get retrieves a value from the session func (s *Session) Get(key string) interface{} { - if v, ok := s.Values[key]; ok { - return v - } - return nil + if v, ok := s.Values[key]; ok { + return v + } + return nil } // Set stores a value in the session func (s *Session) Set(key string, value interface{}) { - s.Values[key] = value + s.Values[key] = value } // Delete removes a value from the session func (s *Session) Delete(key string) { - delete(s.Values, key) + delete(s.Values, key) } diff --git a/pkg/session/store.go b/pkg/session/store.go index 61e7245..b9367bd 100644 --- a/pkg/session/store.go +++ b/pkg/session/store.go @@ -1,162 +1,162 @@ package session import ( - "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/base64" - "encoding/gob" - "errors" - "io" - "net/http" + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/gob" + "errors" + "io" + "net/http" ) // Store defines the interface for session storage implementations type Store interface { - Get(r *http.Request) (*Session, error) - Save(w http.ResponseWriter) error + Get(r *http.Request) (*Session, error) + Save(w http.ResponseWriter) error } const defaultName string = "journal-session" // CookieConfig defines the configuration for session cookies type CookieConfig struct { - Name string - Domain string - MaxAge int - Secure bool - HTTPOnly bool + Name string + Domain string + MaxAge int + Secure bool + HTTPOnly bool } // DefaultStore implements Store using encrypted cookies for session storage type DefaultStore struct { - cachedSession *Session - key []byte - name string - config CookieConfig + cachedSession *Session + key []byte + name string + config CookieConfig } // NewDefaultStore creates a new DefaultStore with the given encryption key and cookie configuration. // The key must be exactly 32 bytes (for AES-256) and contain only printable ASCII characters. func NewDefaultStore(key string, config CookieConfig) (*DefaultStore, error) { - if len(key) != 32 { - return nil, errors.New("session key must be exactly 32 bytes") - } - - for i := 0; i < len(key); i++ { - if key[i] < 32 || key[i] > 126 { - return nil, errors.New("session key must contain only printable ASCII characters") - } - } - - name := config.Name - if name == "" { - name = defaultName - } - - return &DefaultStore{ - key: []byte(key), - name: name, - config: config, - }, nil + if len(key) != 32 { + return nil, errors.New("session key must be exactly 32 bytes") + } + + for i := 0; i < len(key); i++ { + if key[i] < 32 || key[i] > 126 { + return nil, errors.New("session key must contain only printable ASCII characters") + } + } + + name := config.Name + if name == "" { + name = defaultName + } + + return &DefaultStore{ + key: []byte(key), + name: name, + config: config, + }, nil } // Get retrieves the session from the request cookie, decrypting and deserializing it. // If no session exists, a new empty session is created. func (s *DefaultStore) Get(r *http.Request) (*Session, error) { - var err error - if s.cachedSession == nil { - session := NewSession() - c, err := r.Cookie(s.name) - if err == nil { - err = s.decrypt(c.Value, &session.Values) - } else { - err = nil - } - if err == nil { - s.cachedSession = session - } else { - s.cachedSession = NewSession() - } - } - - return s.cachedSession, err + var err error + if s.cachedSession == nil { + session := NewSession() + c, err := r.Cookie(s.name) + if err == nil { + err = s.decrypt(c.Value, &session.Values) + } else { + err = nil + } + if err == nil { + s.cachedSession = session + } else { + s.cachedSession = NewSession() + } + } + + return s.cachedSession, err } // Save encrypts and serializes the session, writing it to a cookie in the response. func (s *DefaultStore) Save(w http.ResponseWriter) error { - encrypted, err := s.encrypt(s.cachedSession.Values) - if err != nil { - return err - } - - http.SetCookie(w, &http.Cookie{ - Name: s.name, - Value: encrypted, - Path: "/", - Domain: s.config.Domain, - MaxAge: s.config.MaxAge, - Secure: s.config.Secure, - SameSite: http.SameSiteStrictMode, - HttpOnly: s.config.HTTPOnly, - }) - - return nil + encrypted, err := s.encrypt(s.cachedSession.Values) + if err != nil { + return err + } + + http.SetCookie(w, &http.Cookie{ + Name: s.name, + Value: encrypted, + Path: "/", + Domain: s.config.Domain, + MaxAge: s.config.MaxAge, + Secure: s.config.Secure, + SameSite: http.SameSiteStrictMode, + HttpOnly: s.config.HTTPOnly, + }) + + return nil } func (s *DefaultStore) decrypt(encrypted string, output interface{}) error { - c, err := aes.NewCipher(s.key) - if err != nil { - return err - } - - gcm, err := cipher.NewGCM(c) - if err != nil { - return err - } - - nonceSize := gcm.NonceSize() - asBytes, err := base64.URLEncoding.DecodeString(encrypted) - if err != nil || len(asBytes) < nonceSize { - return errors.New("string length too short") - } - - nonce, asBytes := asBytes[:nonceSize], asBytes[nonceSize:] - decrypted, err := gcm.Open(nil, nonce, asBytes, nil) - if err != nil { - return err - } - - gob.Register(map[string]interface{}{}) - gob.Register([]interface{}{}) - dec := gob.NewDecoder(bytes.NewBuffer(decrypted)) - return dec.Decode(output) + c, err := aes.NewCipher(s.key) + if err != nil { + return err + } + + gcm, err := cipher.NewGCM(c) + if err != nil { + return err + } + + nonceSize := gcm.NonceSize() + asBytes, err := base64.URLEncoding.DecodeString(encrypted) + if err != nil || len(asBytes) < nonceSize { + return errors.New("string length too short") + } + + nonce, asBytes := asBytes[:nonceSize], asBytes[nonceSize:] + decrypted, err := gcm.Open(nil, nonce, asBytes, nil) + if err != nil { + return err + } + + gob.Register(map[string]interface{}{}) + gob.Register([]interface{}{}) + dec := gob.NewDecoder(bytes.NewBuffer(decrypted)) + return dec.Decode(output) } func (s *DefaultStore) encrypt(input interface{}) (string, error) { - var buf bytes.Buffer - gob.Register(map[string]interface{}{}) - gob.Register([]interface{}{}) - enc := gob.NewEncoder(&buf) - if err := enc.Encode(input); err != nil { - return "", err - } - - c, err := aes.NewCipher(s.key) - if err != nil { - return "", err - } - - gcm, err := cipher.NewGCM(c) - if err != nil { - return "", err - } - - nonce := make([]byte, gcm.NonceSize()) - if _, err = io.ReadFull(rand.Reader, nonce); err != nil { - return "", err - } - - return base64.URLEncoding.EncodeToString(gcm.Seal(nonce, nonce, buf.Bytes(), nil)), nil + var buf bytes.Buffer + gob.Register(map[string]interface{}{}) + gob.Register([]interface{}{}) + enc := gob.NewEncoder(&buf) + if err := enc.Encode(input); err != nil { + return "", err + } + + c, err := aes.NewCipher(s.key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(c) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + return base64.URLEncoding.EncodeToString(gcm.Seal(nonce, nonce, buf.Bytes(), nil)), nil } diff --git a/pkg/session/store_test.go b/pkg/session/store_test.go index 6c5c227..6bc980a 100644 --- a/pkg/session/store_test.go +++ b/pkg/session/store_test.go @@ -1,334 +1,334 @@ package session import ( - "net/http" - "net/http/httptest" - "testing" + "net/http" + "net/http/httptest" + "testing" ) func TestNewDefaultStore(t *testing.T) { - tests := []struct { - name string - key string - config CookieConfig - expectError bool - errorMsg string - }{ - { - name: "Valid 32-byte key", - key: "12345678901234567890123456789012", - config: CookieConfig{ - Name: "test-session", - Domain: "example.com", - MaxAge: 3600, - Secure: true, - HTTPOnly: true, - }, - expectError: false, - }, - { - name: "Key too short", - key: "tooshort", - config: CookieConfig{ - Name: "test-session", - }, - expectError: true, - errorMsg: "session key must be exactly 32 bytes", - }, - { - name: "Key too long", - key: "123456789012345678901234567890123", - config: CookieConfig{ - Name: "test-session", - }, - expectError: true, - errorMsg: "session key must be exactly 32 bytes", - }, - { - name: "Invalid characters in key", - key: "123456789012345678901234\x00\x01\x02\x03\x04\x05\x06\x07", - config: CookieConfig{ - Name: "test-session", - }, - expectError: true, - errorMsg: "session key must contain only printable ASCII characters", - }, - { - name: "Default cookie name when empty", - key: "12345678901234567890123456789012", - config: CookieConfig{ - Name: "", - }, - expectError: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - store, err := NewDefaultStore(test.key, test.config) - - if test.expectError { - if err == nil { - t.Errorf("Expected error but got none") - } else if err.Error() != test.errorMsg { - t.Errorf("Expected error %q, got %q", test.errorMsg, err.Error()) - } - } else { - if err != nil { - t.Errorf("Expected no error but got: %v", err) - } - if store == nil { - t.Errorf("Expected store to be created but got nil") - } - if test.config.Name == "" && store.name != "journal-session" { - t.Errorf("Expected default name 'journal-session', got %q", store.name) - } - if test.config.Name != "" && store.name != test.config.Name { - t.Errorf("Expected name %q, got %q", test.config.Name, store.name) - } - } - }) - } + tests := []struct { + name string + key string + config CookieConfig + expectError bool + errorMsg string + }{ + { + name: "Valid 32-byte key", + key: "12345678901234567890123456789012", + config: CookieConfig{ + Name: "test-session", + Domain: "example.com", + MaxAge: 3600, + Secure: true, + HTTPOnly: true, + }, + expectError: false, + }, + { + name: "Key too short", + key: "tooshort", + config: CookieConfig{ + Name: "test-session", + }, + expectError: true, + errorMsg: "session key must be exactly 32 bytes", + }, + { + name: "Key too long", + key: "123456789012345678901234567890123", + config: CookieConfig{ + Name: "test-session", + }, + expectError: true, + errorMsg: "session key must be exactly 32 bytes", + }, + { + name: "Invalid characters in key", + key: "123456789012345678901234\x00\x01\x02\x03\x04\x05\x06\x07", + config: CookieConfig{ + Name: "test-session", + }, + expectError: true, + errorMsg: "session key must contain only printable ASCII characters", + }, + { + name: "Default cookie name when empty", + key: "12345678901234567890123456789012", + config: CookieConfig{ + Name: "", + }, + expectError: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store, err := NewDefaultStore(test.key, test.config) + + if test.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if err.Error() != test.errorMsg { + t.Errorf("Expected error %q, got %q", test.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + } + if store == nil { + t.Errorf("Expected store to be created but got nil") + } + if test.config.Name == "" && store.name != "journal-session" { + t.Errorf("Expected default name 'journal-session', got %q", store.name) + } + if test.config.Name != "" && store.name != test.config.Name { + t.Errorf("Expected name %q, got %q", test.config.Name, store.name) + } + } + }) + } } func TestEncryptDecryptCycle(t *testing.T) { - key := "12345678901234567890123456789012" - config := CookieConfig{ - Name: "test-session", - Domain: "", - MaxAge: 3600, - Secure: false, - HTTPOnly: true, - } - - store, err := NewDefaultStore(key, config) - if err != nil { - t.Fatalf("Failed to create store: %v", err) - } - - testData := map[string]interface{}{ - "user_id": "12345", - "name": "Test User", - "count": 42, - "active": true, - } - - encrypted, err := store.encrypt(testData) - if err != nil { - t.Fatalf("Failed to encrypt: %v", err) - } - - if encrypted == "" { - t.Errorf("Encrypted string should not be empty") - } - - var decrypted map[string]interface{} - err = store.decrypt(encrypted, &decrypted) - if err != nil { - t.Fatalf("Failed to decrypt: %v", err) - } - - if decrypted["user_id"] != testData["user_id"] { - t.Errorf("Expected user_id %v, got %v", testData["user_id"], decrypted["user_id"]) - } - if decrypted["name"] != testData["name"] { - t.Errorf("Expected name %v, got %v", testData["name"], decrypted["name"]) - } + key := "12345678901234567890123456789012" + config := CookieConfig{ + Name: "test-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: true, + } + + store, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + testData := map[string]interface{}{ + "user_id": "12345", + "name": "Test User", + "count": 42, + "active": true, + } + + encrypted, err := store.encrypt(testData) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + + if encrypted == "" { + t.Errorf("Encrypted string should not be empty") + } + + var decrypted map[string]interface{} + err = store.decrypt(encrypted, &decrypted) + if err != nil { + t.Fatalf("Failed to decrypt: %v", err) + } + + if decrypted["user_id"] != testData["user_id"] { + t.Errorf("Expected user_id %v, got %v", testData["user_id"], decrypted["user_id"]) + } + if decrypted["name"] != testData["name"] { + t.Errorf("Expected name %v, got %v", testData["name"], decrypted["name"]) + } } func TestCookieConfiguration(t *testing.T) { - tests := []struct { - name string - config CookieConfig - }{ - { - name: "Secure cookie with HTTPOnly", - config: CookieConfig{ - Name: "secure-session", - Domain: "example.com", - MaxAge: 7200, - Secure: true, - HTTPOnly: true, - }, - }, - { - name: "Non-secure cookie without HTTPOnly", - config: CookieConfig{ - Name: "insecure-session", - Domain: "", - MaxAge: 3600, - Secure: false, - HTTPOnly: false, - }, - }, - { - name: "Custom domain cookie", - config: CookieConfig{ - Name: "domain-session", - Domain: "example.com", - MaxAge: 1800, - Secure: true, - HTTPOnly: true, - }, - }, - { - name: "Long expiry cookie", - config: CookieConfig{ - Name: "long-session", - Domain: "", - MaxAge: 2592000, - Secure: false, - HTTPOnly: true, - }, - }, - } - - key := "12345678901234567890123456789012" - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - store, err := NewDefaultStore(key, test.config) - if err != nil { - t.Fatalf("Failed to create store: %v", err) - } - - session := NewSession() - session.Set("test", "value") - store.cachedSession = session - - w := httptest.NewRecorder() - err = store.Save(w) - if err != nil { - t.Fatalf("Failed to save session: %v", err) - } - - cookies := w.Result().Cookies() - if len(cookies) != 1 { - t.Fatalf("Expected 1 cookie, got %d", len(cookies)) - } - - cookie := cookies[0] - - if cookie.Name != test.config.Name { - t.Errorf("Expected cookie name %q, got %q", test.config.Name, cookie.Name) - } - if cookie.Domain != test.config.Domain { - t.Errorf("Expected cookie domain %q, got %q", test.config.Domain, cookie.Domain) - } - if cookie.MaxAge != test.config.MaxAge { - t.Errorf("Expected cookie MaxAge %d, got %d", test.config.MaxAge, cookie.MaxAge) - } - if cookie.Secure != test.config.Secure { - t.Errorf("Expected cookie Secure %v, got %v", test.config.Secure, cookie.Secure) - } - if cookie.HttpOnly != test.config.HTTPOnly { - t.Errorf("Expected cookie HttpOnly %v, got %v", test.config.HTTPOnly, cookie.HttpOnly) - } - if cookie.Path != "/" { - t.Errorf("Expected cookie Path '/', got %q", cookie.Path) - } - if cookie.SameSite != http.SameSiteStrictMode { - t.Errorf("Expected cookie SameSite Strict, got %v", cookie.SameSite) - } - }) - } + tests := []struct { + name string + config CookieConfig + }{ + { + name: "Secure cookie with HTTPOnly", + config: CookieConfig{ + Name: "secure-session", + Domain: "example.com", + MaxAge: 7200, + Secure: true, + HTTPOnly: true, + }, + }, + { + name: "Non-secure cookie without HTTPOnly", + config: CookieConfig{ + Name: "insecure-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: false, + }, + }, + { + name: "Custom domain cookie", + config: CookieConfig{ + Name: "domain-session", + Domain: "example.com", + MaxAge: 1800, + Secure: true, + HTTPOnly: true, + }, + }, + { + name: "Long expiry cookie", + config: CookieConfig{ + Name: "long-session", + Domain: "", + MaxAge: 2592000, + Secure: false, + HTTPOnly: true, + }, + }, + } + + key := "12345678901234567890123456789012" + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store, err := NewDefaultStore(key, test.config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + session := NewSession() + session.Set("test", "value") + store.cachedSession = session + + w := httptest.NewRecorder() + err = store.Save(w) + if err != nil { + t.Fatalf("Failed to save session: %v", err) + } + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("Expected 1 cookie, got %d", len(cookies)) + } + + cookie := cookies[0] + + if cookie.Name != test.config.Name { + t.Errorf("Expected cookie name %q, got %q", test.config.Name, cookie.Name) + } + if cookie.Domain != test.config.Domain { + t.Errorf("Expected cookie domain %q, got %q", test.config.Domain, cookie.Domain) + } + if cookie.MaxAge != test.config.MaxAge { + t.Errorf("Expected cookie MaxAge %d, got %d", test.config.MaxAge, cookie.MaxAge) + } + if cookie.Secure != test.config.Secure { + t.Errorf("Expected cookie Secure %v, got %v", test.config.Secure, cookie.Secure) + } + if cookie.HttpOnly != test.config.HTTPOnly { + t.Errorf("Expected cookie HttpOnly %v, got %v", test.config.HTTPOnly, cookie.HttpOnly) + } + if cookie.Path != "/" { + t.Errorf("Expected cookie Path '/', got %q", cookie.Path) + } + if cookie.SameSite != http.SameSiteStrictMode { + t.Errorf("Expected cookie SameSite Strict, got %v", cookie.SameSite) + } + }) + } } func TestGetSession(t *testing.T) { - key := "12345678901234567890123456789012" - config := CookieConfig{ - Name: "test-session", - Domain: "", - MaxAge: 3600, - Secure: false, - HTTPOnly: true, - } - - store, err := NewDefaultStore(key, config) - if err != nil { - t.Fatalf("Failed to create store: %v", err) - } - - t.Run("Get session without cookie", func(t *testing.T) { - req := httptest.NewRequest("GET", "/", nil) - session, err := store.Get(req) - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if session == nil { - t.Errorf("Expected session to be created") - } - }) - - t.Run("Get session with valid cookie", func(t *testing.T) { - session := NewSession() - session.Set("user", "testuser") - store.cachedSession = session - - w := httptest.NewRecorder() - err := store.Save(w) - if err != nil { - t.Fatalf("Failed to save session: %v", err) - } - - cookies := w.Result().Cookies() - if len(cookies) != 1 { - t.Fatalf("Expected 1 cookie, got %d", len(cookies)) - } - - newStore, err := NewDefaultStore(key, config) - if err != nil { - t.Fatalf("Failed to create new store: %v", err) - } - - req := httptest.NewRequest("GET", "/", nil) - req.AddCookie(cookies[0]) - - retrievedSession, err := newStore.Get(req) - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if retrievedSession == nil { - t.Fatalf("Expected session to be retrieved") - } - - user := retrievedSession.Get("user") - if user == nil { - t.Errorf("Expected 'user' key to exist in session") - } - if user != "testuser" { - t.Errorf("Expected user 'testuser', got %v", user) - } - }) + key := "12345678901234567890123456789012" + config := CookieConfig{ + Name: "test-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: true, + } + + store, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + t.Run("Get session without cookie", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + session, err := store.Get(req) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if session == nil { + t.Errorf("Expected session to be created") + } + }) + + t.Run("Get session with valid cookie", func(t *testing.T) { + session := NewSession() + session.Set("user", "testuser") + store.cachedSession = session + + w := httptest.NewRecorder() + err := store.Save(w) + if err != nil { + t.Fatalf("Failed to save session: %v", err) + } + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("Expected 1 cookie, got %d", len(cookies)) + } + + newStore, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create new store: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(cookies[0]) + + retrievedSession, err := newStore.Get(req) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if retrievedSession == nil { + t.Fatalf("Expected session to be retrieved") + } + + user := retrievedSession.Get("user") + if user == nil { + t.Errorf("Expected 'user' key to exist in session") + } + if user != "testuser" { + t.Errorf("Expected user 'testuser', got %v", user) + } + }) } func TestSessionCaching(t *testing.T) { - key := "12345678901234567890123456789012" - config := CookieConfig{ - Name: "test-session", - Domain: "", - MaxAge: 3600, - Secure: false, - HTTPOnly: true, - } - - store, err := NewDefaultStore(key, config) - if err != nil { - t.Fatalf("Failed to create store: %v", err) - } - - req := httptest.NewRequest("GET", "/", nil) - session1, err := store.Get(req) - if err != nil { - t.Fatalf("Failed to get session: %v", err) - } - - session2, err := store.Get(req) - if err != nil { - t.Fatalf("Failed to get session second time: %v", err) - } - - if session1 != session2 { - t.Errorf("Expected same session instance to be returned (cached)") - } + key := "12345678901234567890123456789012" + config := CookieConfig{ + Name: "test-session", + Domain: "", + MaxAge: 3600, + Secure: false, + HTTPOnly: true, + } + + store, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + session1, err := store.Get(req) + if err != nil { + t.Fatalf("Failed to get session: %v", err) + } + + session2, err := store.Get(req) + if err != nil { + t.Fatalf("Failed to get session second time: %v", err) + } + + if session1 != session2 { + t.Errorf("Expected same session instance to be returned (cached)") + } } diff --git a/test/mocks/adapter/adapter.go b/test/mocks/adapter/adapter.go index 6f3b5f7..64c1e73 100644 --- a/test/mocks/adapter/adapter.go +++ b/test/mocks/adapter/adapter.go @@ -1,26 +1,26 @@ package adapter import ( - "bytes" - "encoding/json" - "errors" + "bytes" + "encoding/json" + "errors" ) // MockClient Mock an HTTP client type MockClient struct { - ErrorMode bool - Response string + ErrorMode bool + Response string } // Get Trigger a fake GET, with JSON func (m *MockClient) Get(url string, destination interface{}) error { - if m.ErrorMode { - return errors.New("Simulated error") - } + if m.ErrorMode { + return errors.New("Simulated error") + } - if m.Response != "" { - json.NewDecoder(bytes.NewBufferString(m.Response)).Decode(&destination) - } + if m.Response != "" { + json.NewDecoder(bytes.NewBufferString(m.Response)).Decode(&destination) + } - return nil + return nil } diff --git a/test/mocks/controller/controller.go b/test/mocks/controller/controller.go index f1032ff..9d198c7 100644 --- a/test/mocks/controller/controller.go +++ b/test/mocks/controller/controller.go @@ -1,80 +1,80 @@ package controller import ( - "net/http" - "strings" + "net/http" + "strings" - "github.com/jamiefdhurst/journal/pkg/session" + "github.com/jamiefdhurst/journal/pkg/session" ) // MockController Mock the controller interface type MockController struct { - HasRun bool - session *session.Session + HasRun bool + session *session.Session } // Init Mock the init method func (m *MockController) Init(app interface{}, params []string, request *http.Request) { - m.session = session.NewSession() + m.session = session.NewSession() } // Run Mock the run method func (m *MockController) Run(response http.ResponseWriter, request *http.Request) { - m.HasRun = true + m.HasRun = true } func (m *MockController) Container() interface{} { - var r interface{} - return r + var r interface{} + return r } func (m *MockController) Host() string { - return "foobar.com" + return "foobar.com" } func (m *MockController) Params() []string { - return []string{} + return []string{} } func (m *MockController) SaveSession(w http.ResponseWriter) {} func (m *MockController) Session() *session.Session { - return m.session + return m.session } // MockResponse Mock for http.ResponseWriter type MockResponse struct { - Content string - Headers http.Header - StatusCode int + Content string + Headers http.Header + StatusCode int } // Header Return Headers map func (m *MockResponse) Header() http.Header { - return m.Headers + return m.Headers } // Reset Reset the struct func (m *MockResponse) Reset() { - m.Content = "" - m.Headers = make(http.Header) - m.StatusCode = 200 + m.Content = "" + m.Headers = make(http.Header) + m.StatusCode = 200 } // Write Write the response func (m *MockResponse) Write(b []byte) (int, error) { - m.Content = strings.Join([]string{m.Content, string(b[:])}, "") - return len(b), nil + m.Content = strings.Join([]string{m.Content, string(b[:])}, "") + return len(b), nil } // WriteHeader Write the status code func (m *MockResponse) WriteHeader(statusCode int) { - m.StatusCode = statusCode + m.StatusCode = statusCode } // NewMockResponse Make a mock response func NewMockResponse() *MockResponse { - m := &MockResponse{} - m.Headers = make(http.Header) - return m + m := &MockResponse{} + m.Headers = make(http.Header) + return m } diff --git a/test/mocks/database/database.go b/test/mocks/database/database.go index 034c170..f87c577 100644 --- a/test/mocks/database/database.go +++ b/test/mocks/database/database.go @@ -1,11 +1,11 @@ package database import ( - "database/sql" - "errors" - "time" + "database/sql" + "errors" + "time" - "github.com/jamiefdhurst/journal/pkg/database/rows" + "github.com/jamiefdhurst/journal/pkg/database/rows" ) // MockDatabase Mock for model.Database @@ -16,110 +16,110 @@ func (m *MockDatabase) Close() {} // Connect Mock the connect method func (m *MockDatabase) Connect(dbFile string) error { - return nil + return nil } // Exec Mock empty exec func (m *MockDatabase) Exec(sql string, args ...interface{}) (sql.Result, error) { - return nil, nil + return nil, nil } // Query Mock empty query func (m *MockDatabase) Query(sql string, args ...interface{}) (rows.Rows, error) { - return nil, nil + return nil, nil } // MockJournal_MultipleRows Mock multiple rows returned for a Journal type MockJournal_MultipleRows struct { - MockRowsEmpty - RowNumber int + MockRowsEmpty + RowNumber int } // Next Mock 2 rows func (m *MockJournal_MultipleRows) Next() bool { - m.RowNumber++ - if m.RowNumber < 3 { - return true - } - return false + m.RowNumber++ + if m.RowNumber < 3 { + return true + } + return false } // Scan Return the data func (m *MockJournal_MultipleRows) Scan(dest ...interface{}) error { - if m.RowNumber == 1 { - *dest[0].(*int) = 1 - *dest[1].(*string) = "slug" - *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 + if m.RowNumber == 1 { + *dest[0].(*int) = 1 + *dest[1].(*string) = "slug" + *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 } // MockJournal_SingleRow Mock single row returned for a Journal type MockJournal_SingleRow struct { - MockRowsEmpty - RowNumber int + MockRowsEmpty + RowNumber int } // Next Mock 1 row func (m *MockJournal_SingleRow) Next() bool { - m.RowNumber++ - if m.RowNumber < 2 { - return true - } - return false + m.RowNumber++ + if m.RowNumber < 2 { + return true + } + return false } // Scan Return the data func (m *MockJournal_SingleRow) Scan(dest ...interface{}) error { - if m.RowNumber == 1 { - *dest[0].(*int) = 1 - *dest[1].(*string) = "slug" - *dest[2].(*string) = "Title" - *dest[3].(*string) = "2018-02-01" - *dest[4].(*string) = "Content" - createdAt := time.Date(2018, 2, 1, 10, 0, 0, 0, time.UTC) - updatedAt := time.Date(2018, 2, 1, 10, 0, 0, 0, time.UTC) - *dest[5].(**time.Time) = &createdAt - *dest[6].(**time.Time) = &updatedAt - } - return nil + if m.RowNumber == 1 { + *dest[0].(*int) = 1 + *dest[1].(*string) = "slug" + *dest[2].(*string) = "Title" + *dest[3].(*string) = "2018-02-01" + *dest[4].(*string) = "Content" + createdAt := time.Date(2018, 2, 1, 10, 0, 0, 0, time.UTC) + updatedAt := time.Date(2018, 2, 1, 10, 0, 0, 0, time.UTC) + *dest[5].(**time.Time) = &createdAt + *dest[6].(**time.Time) = &updatedAt + } + return nil } // MockPagination_Result mocks a pagination result with custom pages type MockPagination_Result struct { - MockRowsEmpty - RowNumber int - TotalResults int + MockRowsEmpty + RowNumber int + TotalResults int } // Next Mock 1 row func (m *MockPagination_Result) Next() bool { - m.RowNumber++ - if m.RowNumber < 2 { - return true - } - return false + m.RowNumber++ + if m.RowNumber < 2 { + return true + } + return false } // Scan Return the data func (m *MockPagination_Result) Scan(dest ...interface{}) error { - if m.RowNumber == 1 { - *dest[0].(*int) = m.TotalResults - } - return nil + if m.RowNumber == 1 { + *dest[0].(*int) = m.TotalResults + } + return nil } // MockResult Mock the result for a saved Journal @@ -127,12 +127,12 @@ type MockResult struct{} // LastInsertId Mock the last inserted ID func (m *MockResult) LastInsertId() (int64, error) { - return 1, nil + return 1, nil } // RowsAffected Mock the rows affected func (m *MockResult) RowsAffected() (int64, error) { - return 0, nil + return 0, nil } // MockRowsEmpty An empty row set @@ -140,191 +140,191 @@ type MockRowsEmpty struct{} // Close Mock close method func (m *MockRowsEmpty) Close() error { - return nil + return nil } // Columns Mock columns method func (m *MockRowsEmpty) Columns() ([]string, error) { - return []string{}, nil + return []string{}, nil } // Next No rows func (m *MockRowsEmpty) Next() bool { - return false + return false } // Scan No rows func (m *MockRowsEmpty) Scan(dest ...interface{}) error { - return nil + return nil } // MockSqlite Mock model.Sqlite allowing injected results, rows and errors type MockSqlite struct { - Closed bool - Connected bool - ErrorAtQuery int - ErrorMode bool - ExpectedArgument string - MultiMode bool - multiResults []rows.Rows - Queries int - Result sql.Result - Rows rows.Rows + Closed bool + Connected bool + ErrorAtQuery int + ErrorMode bool + ExpectedArgument string + MultiMode bool + multiResults []rows.Rows + Queries int + Result sql.Result + Rows rows.Rows } // AppendResult adds one more result func (m *MockSqlite) AppendResult(rows rows.Rows) { - m.multiResults = append(m.multiResults, rows) + m.multiResults = append(m.multiResults, rows) } // Close Mark as closed func (m *MockSqlite) Close() { - m.Closed = true + m.Closed = true } // Connect Mark as connected func (m *MockSqlite) Connect(dbFile string) error { - m.Connected = true - return nil + m.Connected = true + return nil } // EnableMultiMode turns on multi mode func (m *MockSqlite) EnableMultiMode() { - m.multiResults = make([]rows.Rows, 0) - m.MultiMode = true + m.multiResults = make([]rows.Rows, 0) + m.MultiMode = true } // Exec Test arguments and errors func (m *MockSqlite) Exec(sql string, args ...interface{}) (sql.Result, error) { - m.Queries++ - if m.ErrorMode || m.ErrorAtQuery == m.Queries { - return nil, errors.New("Simulating error") - } - if m.ExpectedArgument != "" && !m.inArgs(args) { - return nil, errors.New("Expected " + m.ExpectedArgument + " in query") - } - return m.Result, nil + m.Queries++ + if m.ErrorMode || m.ErrorAtQuery == m.Queries { + return nil, errors.New("Simulating error") + } + if m.ExpectedArgument != "" && !m.inArgs(args) { + return nil, errors.New("Expected " + m.ExpectedArgument + " in query") + } + return m.Result, nil } // Query Test arguments and errors func (m *MockSqlite) Query(sql string, args ...interface{}) (rows.Rows, error) { - m.Queries++ - if m.ErrorMode || m.ErrorAtQuery == m.Queries { - return nil, errors.New("Simulating error") - } - if m.ExpectedArgument != "" && !m.inArgs(args) { - return nil, errors.New("Expected " + m.ExpectedArgument + " in query") - } - if m.MultiMode { - return m.popResult(), nil - } - return m.Rows, nil + m.Queries++ + if m.ErrorMode || m.ErrorAtQuery == m.Queries { + return nil, errors.New("Simulating error") + } + if m.ExpectedArgument != "" && !m.inArgs(args) { + return nil, errors.New("Expected " + m.ExpectedArgument + " in query") + } + if m.MultiMode { + return m.popResult(), nil + } + return m.Rows, nil } func (m *MockSqlite) inArgs(slice []interface{}) bool { - for _, v := range slice { - if v.(string) == m.ExpectedArgument { - return true - } - } - return false + for _, v := range slice { + if v.(string) == m.ExpectedArgument { + return true + } + } + return false } func (m *MockSqlite) popResult() rows.Rows { - result := m.multiResults[0] - if len(m.multiResults) > 1 { - m.multiResults = m.multiResults[1:] - } else { - m.multiResults = make([]rows.Rows, 0) - } + result := m.multiResults[0] + if len(m.multiResults) > 1 { + m.multiResults = m.multiResults[1:] + } else { + m.multiResults = make([]rows.Rows, 0) + } - return result + return result } // MockVisit_SingleRow Mock single row returned for a Visit type MockVisit_SingleRow struct { - MockRowsEmpty - RowNumber int + MockRowsEmpty + RowNumber int } // Next Mock 1 row func (m *MockVisit_SingleRow) Next() bool { - m.RowNumber++ - if m.RowNumber < 2 { - return true - } - return false + m.RowNumber++ + if m.RowNumber < 2 { + return true + } + return false } // Scan Return the visit data func (m *MockVisit_SingleRow) Scan(dest ...interface{}) error { - if m.RowNumber == 1 { - *dest[0].(*int) = 1 - *dest[1].(*string) = "2023-01-01" - *dest[2].(*string) = "/test" - *dest[3].(*int) = 5 - } - return nil + if m.RowNumber == 1 { + *dest[0].(*int) = 1 + *dest[1].(*string) = "2023-01-01" + *dest[2].(*string) = "/test" + *dest[3].(*int) = 5 + } + return nil } // MockVisitStats_DailyRows Mock daily visit statistics rows type MockVisitStats_DailyRows struct { - MockRowsEmpty - RowNumber int + MockRowsEmpty + RowNumber int } // Next Mock 2 rows func (m *MockVisitStats_DailyRows) Next() bool { - m.RowNumber++ - if m.RowNumber < 3 { - return true - } - return false + m.RowNumber++ + if m.RowNumber < 3 { + return true + } + return false } // Scan Return the daily stats data func (m *MockVisitStats_DailyRows) Scan(dest ...interface{}) error { - if m.RowNumber == 1 { - *dest[0].(*string) = "2023-12-25" - *dest[1].(*int) = 15 - *dest[2].(*int) = 42 - *dest[3].(*int) = 57 - } else if m.RowNumber == 2 { - *dest[0].(*string) = "2023-12-24" - *dest[1].(*int) = 8 - *dest[2].(*int) = 25 - *dest[3].(*int) = 33 - } - return nil + if m.RowNumber == 1 { + *dest[0].(*string) = "2023-12-25" + *dest[1].(*int) = 15 + *dest[2].(*int) = 42 + *dest[3].(*int) = 57 + } else if m.RowNumber == 2 { + *dest[0].(*string) = "2023-12-24" + *dest[1].(*int) = 8 + *dest[2].(*int) = 25 + *dest[3].(*int) = 33 + } + return nil } // MockVisitStats_MonthlyRows Mock monthly visit statistics rows type MockVisitStats_MonthlyRows struct { - MockRowsEmpty - RowNumber int + MockRowsEmpty + RowNumber int } // Next Mock 2 rows func (m *MockVisitStats_MonthlyRows) Next() bool { - m.RowNumber++ - if m.RowNumber < 3 { - return true - } - return false + m.RowNumber++ + if m.RowNumber < 3 { + return true + } + return false } // Scan Return the monthly stats data func (m *MockVisitStats_MonthlyRows) Scan(dest ...interface{}) error { - if m.RowNumber == 1 { - *dest[0].(*string) = "2023-12" - *dest[1].(*int) = 450 - *dest[2].(*int) = 1250 - *dest[3].(*int) = 1700 - } else if m.RowNumber == 2 { - *dest[0].(*string) = "2023-11" - *dest[1].(*int) = 320 - *dest[2].(*int) = 980 - *dest[3].(*int) = 1300 - } - return nil + if m.RowNumber == 1 { + *dest[0].(*string) = "2023-12" + *dest[1].(*int) = 450 + *dest[2].(*int) = 1250 + *dest[3].(*int) = 1700 + } else if m.RowNumber == 2 { + *dest[0].(*string) = "2023-11" + *dest[1].(*int) = 320 + *dest[2].(*int) = 980 + *dest[3].(*int) = 1300 + } + return nil } diff --git a/test/mocks/router/router.go b/test/mocks/router/router.go index cbc8f74..212df92 100644 --- a/test/mocks/router/router.go +++ b/test/mocks/router/router.go @@ -2,17 +2,17 @@ package router // MockServer Mock the server interface type MockServer struct { - Listening bool + Listening bool } // ListenAndServe Dummy method func (m *MockServer) ListenAndServe() error { - m.Listening = true - return nil + m.Listening = true + return nil } // ListenAndServeTLS Dummy method func (m *MockServer) ListenAndServeTLS(cert string, key string) error { - m.Listening = true - return nil + m.Listening = true + return nil } From 9935a375131e3faaef5934836624b7afaf84a54d Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 1 Mar 2026 12:31:54 +0000 Subject: [PATCH 47/52] remove lambda mentions and build checks --- .github/workflows/build.yml | 5 +---- Makefile | 6 ++---- README.md | 25 ------------------------- 3 files changed, 3 insertions(+), 33 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a1dd99..0e1532d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,11 +92,8 @@ jobs: cache-dependency-path: go.sum - name: Build Binary run: | - sudo apt-get install -y build-essential libsqlite3-dev go mod download GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal-bin_linux_x64-v${{ steps.version.outputs.value }} ./cmd/journal - cp journal-bin_linux_x64-v${{ steps.version.outputs.value }} bootstrap - zip -r journal-lambda_al2023-v${{ steps.version.outputs.value }}.zip bootstrap web -x web/app/\* - name: Create Release uses: ncipollo/release-action@v1.12.0 with: @@ -105,4 +102,4 @@ jobs: makeLatest: true tag: v${{ steps.version.outputs.value }} name: v${{ steps.version.outputs.value }} - artifacts: "journal-bin_linux_x64-v${{ steps.version.outputs.value }},journal-lambda_al2023-v${{ steps.version.outputs.value }}.zip" + artifacts: "journal-bin_linux_x64-v${{ steps.version.outputs.value }}" diff --git a/Makefile b/Makefile index 9e3d1d4..5308045 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,11 @@ .PHONY: build test build: - @CC=x86_64-unknown-linux-gnu-gcc GOARCH=amd64 GOOS=linux go build -v -o bootstrap ./cmd/journal - @zip -r lambda.zip bootstrap web -x web/app/\* + @go build -v -o journal ./cmd/journal test: @2>&1 go test -coverprofile=cover.out -coverpkg=./internal/...,./pkg/... -v ./... | go2xunit > tests.xml @gocov convert cover.out | gocov-xml > coverage.xml clean: - @rm -f bootstrap - @rm -f lambda.zip \ No newline at end of file + @rm -f journal \ No newline at end of file diff --git a/README.md b/README.md index 81fc5d1..cf647a2 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,6 @@ the binary itself. The application has the following dependencies (using go.mod and go.sum): - [github.com/ncruces/go-sqlite3](https://github.com/ncruces/go-sqlite3) -- [github.com/akrylysov/algnhsa](https://github.com/akrylysov/algnhsa) -- [github.com/aws/aws-lambda-go](https://github.com/aws/aws-lambda-go) - [github.com/gomarkdown/markdown](https://github.com/gomarkdown/markdown) This can be installed using the following commands from the journal folder: @@ -158,26 +156,3 @@ To test locally, simply use: go test -v ./... ``` -### Building for Lambda - -The application is designed to run as a Lambda connected to an EFS for SQLite -storage. This requires a different method of building to ensure it includes the -appropriate libraries and is built for the correct architecture. - -To build for Lambda, you will need the x86_64-unknown-linux-gnu cross compiler -(if you're on a Mac): - -```bash -brew tap SergioBenitez/osxct -brew install x86_64-unknown-linux-gnu -``` - -To build, simply run: - -```bash -make build -``` - -This will produce a Lambda output: `lambda.zip`, that you can upload onto an -Amazon Linux 2023 (al2023) runtime Lambda, if you configure the appropriate -environment variables. From 2b68e1dc23df0660b20ad98685d2ca027823767f Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sat, 7 Mar 2026 21:08:19 +0000 Subject: [PATCH 48/52] Consolidate and improve styling by simplifying and making the calendar more in-line with other aspects --- web/themes/default/style.css | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/web/themes/default/style.css b/web/themes/default/style.css index 718d9e8..b96581c 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -23,7 +23,7 @@ html { body { color: #000; font-family: 'Roboto', sans-serif; - font-size: 1.25rem; + font-size: 1rem; margin: 0; } @@ -144,11 +144,16 @@ article p.date { color: #777; font-size: .9rem; font-weight: 400; + line-height: 1.15rem; margin: 0 auto 2rem; max-width: 700px; padding: 0 0 1rem; } +article p.date em { + font-size: .75rem; +} + article .summary, article .content { margin: 0 auto; @@ -549,13 +554,14 @@ section.stats { } .calendar-top { + border-bottom: 1px solid #dedede; clear: both; position: relative; text-align: center; } .calendar-top h2 { - margin: 0 auto 2rem; + margin: .5rem 0; } .calendar-top .prev { @@ -573,9 +579,10 @@ section.stats { } .calendar { - border: 2px solid #111; + border: 1px solid #dedede; + border-top: 2px solid #111; border-collapse: collapse; - margin-top: 4rem; + margin-top: 2rem; width: 100%; } @@ -598,7 +605,7 @@ section.stats { .calendar td { display: block; - padding: 1.75rem 1rem .5rem; + min-height: 1.125rem; position: relative; } @@ -607,12 +614,15 @@ section.stats { } .calendar td h3 { - font-size: 1.25rem; + background-color: #dedede; + font-size: .9rem; margin: 0; + min-width: 1rem; + padding: .25rem; position: absolute; - right: .5rem; - text-align: right; - top: .5rem; + right: 0; + text-align: center; + top: 0; } @media screen and (min-width: 768px) { @@ -628,7 +638,7 @@ section.stats { } .calendar td a { - font-size: 1rem; + font-size: .9rem; line-height: 1.25rem; } } From ef0b1c3044ba2ae3b0034176491e9bba50e1fa8a Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 8 Mar 2026 12:34:33 +0000 Subject: [PATCH 49/52] improve test coverage --- internal/app/app_test.go | 44 +++ internal/app/controller/apiv1/create_test.go | 1 - internal/app/controller/apiv1/list_test.go | 18 +- internal/app/controller/apiv1/random_test.go | 80 ++-- internal/app/controller/apiv1/single_test.go | 3 +- internal/app/controller/apiv1/stats_test.go | 3 +- internal/app/controller/apiv1/update_test.go | 13 +- .../app/controller/web/badrequest_test.go | 12 - internal/app/controller/web/calendar_test.go | 21 +- internal/app/controller/web/edit_test.go | 62 ++-- internal/app/controller/web/index_test.go | 12 - internal/app/controller/web/new.go | 2 +- internal/app/controller/web/new_test.go | 42 +-- internal/app/controller/web/setup_test.go | 16 + internal/app/controller/web/sitemap_test.go | 12 - internal/app/controller/web/stats_test.go | 12 +- internal/app/controller/web/view_test.go | 12 - internal/app/model/journal_test.go | 103 +++++- internal/app/model/migration_test.go | 347 +++++++++++++----- internal/app/model/visit_test.go | 23 ++ internal/app/router/router_test.go | 26 ++ pkg/controller/controller_test.go | 83 ++++- pkg/database/database_test.go | 26 ++ pkg/database/pagination_test.go | 28 ++ pkg/env/parser_test.go | 8 + pkg/session/session_test.go | 72 ++++ pkg/session/store_test.go | 47 +++ test/mocks/controller/controller.go | 2 +- test/mocks/database/database.go | 51 +-- 29 files changed, 874 insertions(+), 307 deletions(-) create mode 100644 internal/app/controller/web/setup_test.go create mode 100644 internal/app/router/router_test.go create mode 100644 pkg/session/session_test.go diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 7db32f9..96638c9 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -456,6 +456,50 @@ func TestApplyEnvConfiguration_NoDotEnvFile(t *testing.T) { } } +func TestApplyEnvConfiguration_OtherVars(t *testing.T) { + os.Setenv("J_DB_PATH", "/path/to/db.sqlite") + os.Setenv("J_CREATE", "0") + os.Setenv("J_EDIT", "0") + os.Setenv("J_EXCERPT_WORDS", "30") + os.Setenv("J_STATIC_PATH", "/static") + os.Setenv("J_THEME", "custom") + os.Setenv("J_THEME_PATH", "/themes") + defer func() { + os.Unsetenv("J_DB_PATH") + os.Unsetenv("J_CREATE") + os.Unsetenv("J_EDIT") + os.Unsetenv("J_EXCERPT_WORDS") + os.Unsetenv("J_STATIC_PATH") + os.Unsetenv("J_THEME") + os.Unsetenv("J_THEME_PATH") + }() + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.DatabasePath != "/path/to/db.sqlite" { + t.Errorf("Expected DatabasePath '/path/to/db.sqlite', got %q", config.DatabasePath) + } + if config.EnableCreate != false { + t.Error("Expected EnableCreate to be false when J_CREATE=0") + } + if config.EnableEdit != false { + t.Error("Expected EnableEdit to be false when J_EDIT=0") + } + if config.ExcerptWords != 30 { + t.Errorf("Expected ExcerptWords 30, got %d", config.ExcerptWords) + } + if config.StaticPath != "/static" { + t.Errorf("Expected StaticPath '/static', got %q", config.StaticPath) + } + if config.Theme != "custom" { + t.Errorf("Expected Theme 'custom', got %q", config.Theme) + } + if config.ThemePath != "/themes" { + t.Errorf("Expected ThemePath '/themes', got %q", config.ThemePath) + } +} + func TestApplyEnvConfiguration_ArticlesDeprecated(t *testing.T) { // Save current working directory originalWd, _ := os.Getwd() diff --git a/internal/app/controller/apiv1/create_test.go b/internal/app/controller/apiv1/create_test.go index 4ee6d32..67a43e5 100644 --- a/internal/app/controller/apiv1/create_test.go +++ b/internal/app/controller/apiv1/create_test.go @@ -16,7 +16,6 @@ func TestCreate_Run(t *testing.T) { db.Rows = &database.MockRowsEmpty{} container := &app.Container{Configuration: app.DefaultConfiguration(), Db: db} response := controller.NewMockResponse() - response.Reset() controller := &Create{} controller.DisableTracking() diff --git a/internal/app/controller/apiv1/list_test.go b/internal/app/controller/apiv1/list_test.go index 706246e..770a25a 100644 --- a/internal/app/controller/apiv1/list_test.go +++ b/internal/app/controller/apiv1/list_test.go @@ -13,8 +13,7 @@ import ( func TestList_Run(t *testing.T) { db := &database.MockSqlite{} container := &app.Container{Configuration: app.DefaultConfiguration(), Db: db} - response := &controller.MockResponse{} - response.Reset() + response := controller.NewMockResponse() controller := &List{} controller.DisableTracking() @@ -28,4 +27,19 @@ func TestList_Run(t *testing.T) { if !strings.Contains(response.Content, "Title 2") { t.Error("Expected all journals to be returned") } + + // Test with page parameter + response.Reset() + db.EnableMultiMode() + db.AppendResult(&database.MockPagination_Result{TotalResults: 25}) + db.AppendResult(&database.MockJournal_MultipleRows{}) + request, _ = http.NewRequest("GET", "/?page=2", strings.NewReader("")) + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) + if !strings.Contains(response.Content, "Title 2") { + t.Error("Expected journals to be returned for page 2") + } + if !strings.Contains(response.Content, `"current_page":2`) { + t.Error("Expected pagination to reflect page 2") + } } diff --git a/internal/app/controller/apiv1/random_test.go b/internal/app/controller/apiv1/random_test.go index 43426e4..fd947a5 100644 --- a/internal/app/controller/apiv1/random_test.go +++ b/internal/app/controller/apiv1/random_test.go @@ -1,53 +1,43 @@ package apiv1 import ( - "net/http" - "strings" - "testing" + "net/http" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestRandom_Run(t *testing.T) { - response := controller.NewMockResponse() - db := &database.MockSqlite{} - container := &app.Container{Db: db} - random := &Random{} - random.DisableTracking() - - // Test with a journal found - db.Rows = &database.MockJournal_SingleRow{} - request, _ := http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) - random.Init(container, []string{}, request) - response.StatusCode = http.StatusOK // Set a status code since our mock doesn't - response.Headers.Set("Content-Type", "application/json") - response.Content = `{"id":1,"slug":"slug","title":"Title","date":"2018-02-01","content":"Content"}` - random.Run(response, request) - - if response.StatusCode != http.StatusOK { - t.Errorf("Expected OK, got status %d", response.StatusCode) - } - - if contentType := response.Headers.Get("Content-Type"); contentType != "application/json" { - t.Errorf("Expected json content type, got %s", contentType) - } - - // In a real test, we would decode the JSON response, but we're mocking it - // with a hard-coded valid response, so we can just check that we have content - if response.Content == "" { - t.Error("Expected JSON response content, got empty response") - } - - // Test with no journal found - response = controller.NewMockResponse() - db.Rows = &database.MockRowsEmpty{} - request, _ = http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) - random.Init(container, []string{}, request) - random.Run(response, request) - - if response.StatusCode != http.StatusNotFound { - t.Errorf("Expected not found, got status %d", response.StatusCode) - } + db := &database.MockSqlite{} + container := &app.Container{Db: db} + random := &Random{} + random.DisableTracking() + + // Test with a journal found + response := controller.NewMockResponse() + db.Rows = &database.MockJournal_SingleRow{} + request, _ := http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) + random.Init(container, []string{}, request) + random.Run(response, request) + + if response.Headers.Get("Content-Type") != "application/json" { + t.Errorf("Expected application/json content type, got %s", response.Headers.Get("Content-Type")) + } + if !strings.Contains(response.Content, `"url"`) || !strings.Contains(response.Content, `"Title"`) { + t.Errorf("Expected JSON response with journal data, got: %s", response.Content) + } + + // Test with no journal found + response = controller.NewMockResponse() + db.Rows = &database.MockRowsEmpty{} + request, _ = http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) + random.Init(container, []string{}, request) + random.Run(response, request) + + if response.StatusCode != http.StatusNotFound { + t.Errorf("Expected 404 not found, got status %d", response.StatusCode) + } } diff --git a/internal/app/controller/apiv1/single_test.go b/internal/app/controller/apiv1/single_test.go index 2c404fb..2e37b74 100644 --- a/internal/app/controller/apiv1/single_test.go +++ b/internal/app/controller/apiv1/single_test.go @@ -13,8 +13,7 @@ import ( func TestSingle_Run(t *testing.T) { db := &database.MockSqlite{} container := &app.Container{Db: db} - response := &controller.MockResponse{} - response.Reset() + response := controller.NewMockResponse() controller := &Single{} controller.DisableTracking() diff --git a/internal/app/controller/apiv1/stats_test.go b/internal/app/controller/apiv1/stats_test.go index c352eb8..c137ced 100644 --- a/internal/app/controller/apiv1/stats_test.go +++ b/internal/app/controller/apiv1/stats_test.go @@ -16,8 +16,7 @@ func TestStats_Run(t *testing.T) { configuration.PostsPerPage = 25 // Custom setting configuration.GoogleAnalyticsCode = "UA-123456" // Custom GA code container := &app.Container{Configuration: configuration, Db: db} - response := &controller.MockResponse{} - response.Reset() + response := controller.NewMockResponse() controller := &Stats{} controller.DisableTracking() diff --git a/internal/app/controller/apiv1/update_test.go b/internal/app/controller/apiv1/update_test.go index ac0fd7b..0d6558d 100644 --- a/internal/app/controller/apiv1/update_test.go +++ b/internal/app/controller/apiv1/update_test.go @@ -13,8 +13,7 @@ import ( func TestUpdate_Run(t *testing.T) { db := &database.MockSqlite{} container := &app.Container{Configuration: app.DefaultConfiguration(), Db: db} - response := &controller.MockResponse{} - response.Reset() + response := controller.NewMockResponse() controller := &Update{} controller.DisableTracking() @@ -48,6 +47,16 @@ func TestUpdate_Run(t *testing.T) { t.Error("Expected 400 error when invalid JSON provided") } + // Test validation failure (reserved slug) + response.Reset() + request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("{\"title\":\"random\"}")) + request.Header.Add("Content-Type", "application/json") + db.Rows = &database.MockJournal_SingleRow{} + controller.Run(response, request) + if response.StatusCode != 400 { + t.Error("Expected 400 when updated data fails validation") + } + // Test Journal is retrieved on save response.Reset() request, _ = http.NewRequest("POST", "/slug/edit", strings.NewReader("{\"title\":\"Something New\",\"date\":\"2018-01-01\",\"content\":\"New\"}")) diff --git a/internal/app/controller/web/badrequest_test.go b/internal/app/controller/web/badrequest_test.go index 5a37f0a..6c2389e 100644 --- a/internal/app/controller/web/badrequest_test.go +++ b/internal/app/controller/web/badrequest_test.go @@ -2,9 +2,6 @@ package web import ( "net/http" - "os" - "path" - "runtime" "strings" "testing" @@ -12,15 +9,6 @@ import ( "github.com/jamiefdhurst/journal/test/mocks/controller" ) -func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } -} - func TestError_Run(t *testing.T) { response := controller.NewMockResponse() configuration := app.DefaultConfiguration() diff --git a/internal/app/controller/web/calendar_test.go b/internal/app/controller/web/calendar_test.go index 7d9e036..54e865f 100644 --- a/internal/app/controller/web/calendar_test.go +++ b/internal/app/controller/web/calendar_test.go @@ -2,9 +2,6 @@ package web import ( "net/http" - "os" - "path" - "runtime" "strconv" "strings" "testing" @@ -15,15 +12,6 @@ import ( "github.com/jamiefdhurst/journal/test/mocks/database" ) -func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } -} - func TestCalendarRun(t *testing.T) { db := &database.MockSqlite{} configuration := app.DefaultConfiguration() @@ -122,6 +110,15 @@ func TestCalendarRun(t *testing.T) { t.Error("Expected next month link to be shown") } + // Test invalid month name triggers error + response.Reset() + request, _ = http.NewRequest("GET", "/calendar/2019/notamonth", strings.NewReader("")) + controller.Init(container, []string{"", "2019", "notamonth"}, request) + controller.Run(response, request) + if response.StatusCode != 404 { + t.Error("Expected 404 for invalid month name") + } + // Test year only response.Reset() db.EnableMultiMode() diff --git a/internal/app/controller/web/edit_test.go b/internal/app/controller/web/edit_test.go index c7eb446..bbdca64 100644 --- a/internal/app/controller/web/edit_test.go +++ b/internal/app/controller/web/edit_test.go @@ -2,9 +2,6 @@ package web import ( "net/http" - "os" - "path" - "runtime" "strings" "testing" @@ -13,15 +10,6 @@ import ( "github.com/jamiefdhurst/journal/test/mocks/database" ) -func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } -} - func TestEdit_Run(t *testing.T) { db := &database.MockSqlite{} configuration := app.DefaultConfiguration() @@ -32,9 +20,21 @@ func TestEdit_Run(t *testing.T) { controller := &Edit{} controller.DisableTracking() + // Test forbidden when editing is disabled + db.Rows = &database.MockRowsEmpty{} + container.Configuration.EnableEdit = false + request, _ := http.NewRequest("GET", "/slug/edit", strings.NewReader("")) + controller.Init(container, []string{"", "slug"}, request) + controller.Run(response, request) + if response.StatusCode != 404 || !strings.Contains(response.Content, "Page Not Found") { + t.Error("Expected error page when editing is disabled") + } + container.Configuration.EnableEdit = true + // Test not found/error with GET/POST + response.Reset() db.Rows = &database.MockRowsEmpty{} - request := &http.Request{Method: "GET"} + request = &http.Request{Method: "GET"} controller.Init(container, []string{"", "0"}, request) controller.Run(response, request) if response.StatusCode != 404 || !strings.Contains(response.Content, "Page Not Found") { @@ -64,6 +64,24 @@ func TestEdit_Run(t *testing.T) { t.Error("Expected error to be shown in form") } + // Display error with form data pre-populated from session + // Sets flash + form_data directly on the in-memory session, then calls Run, + // which exercises the RenderFromSession form_data branch. + response.Reset() + request, _ = http.NewRequest("GET", "/slug/edit", strings.NewReader("")) + db.Rows = &database.MockJournal_SingleRow{} + controller.Init(container, []string{"", "slug"}, request) + controller.Session().AddFlash("error") + controller.Session().Set("form_data", map[string]string{ + "title": "Restored Title", + "date": "2025-01-01", + "content": "Restored Content", + }) + controller.Run(response, request) + if !strings.Contains(response.Content, "Restored Title") { + t.Error("Expected form to be pre-populated with title from session") + } + // Display no error response.Reset() request, _ = http.NewRequest("GET", "/slug/edit", strings.NewReader("")) @@ -88,16 +106,6 @@ func TestEdit_Run(t *testing.T) { t.Error("Expected redirect back to same page") } - // Validate error cookie on redirect - // We need to create a new controller with the cookie to test flash values - newController := &Edit{} - newController.DisableTracking() - request, _ = http.NewRequest("GET", "/", strings.NewReader("")) - request.Header.Add("Cookie", response.Headers.Get("Set-Cookie")) - newController.Init(container, []string{"", "0"}, request) - // Skip GetFlash since we only care that an error flash was added - // We can verify the redirect is correct - // Test form data preservation when validation fails response.Reset() // Create a new controller instance for this test @@ -144,12 +152,4 @@ func TestEdit_Run(t *testing.T) { t.Error("Expected redirect back to home with saved banner shown") } - // Validate saved cookie on redirect - // We need to create a new controller with the cookie to test flash values - saveController := &Edit{} - request, _ = http.NewRequest("GET", "/", strings.NewReader("")) - request.Header.Add("Cookie", response.Headers.Get("Set-Cookie")) - saveController.Init(container, []string{"", "0"}, request) - // Skip GetFlash since we only care that a saved flash was added - // We can verify the redirect is correct } diff --git a/internal/app/controller/web/index_test.go b/internal/app/controller/web/index_test.go index 2bba249..3966056 100644 --- a/internal/app/controller/web/index_test.go +++ b/internal/app/controller/web/index_test.go @@ -2,9 +2,6 @@ package web import ( "net/http" - "os" - "path" - "runtime" "strings" "testing" @@ -13,15 +10,6 @@ import ( "github.com/jamiefdhurst/journal/test/mocks/database" ) -func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } -} - func TestIndex_Run(t *testing.T) { db := &database.MockSqlite{} configuration := app.DefaultConfiguration() diff --git a/internal/app/controller/web/new.go b/internal/app/controller/web/new.go index f26de6d..de29a67 100644 --- a/internal/app/controller/web/new.go +++ b/internal/app/controller/web/new.go @@ -21,7 +21,7 @@ func (c *New) Run(response http.ResponseWriter, request *http.Request) { container := c.Super.Container().(*app.Container) data.Container = container if !container.Configuration.EnableCreate { - RunBadRequest(response, request, c.Super.Container) + RunBadRequest(response, request, container) return } diff --git a/internal/app/controller/web/new_test.go b/internal/app/controller/web/new_test.go index f02e5a1..2064134 100644 --- a/internal/app/controller/web/new_test.go +++ b/internal/app/controller/web/new_test.go @@ -2,9 +2,6 @@ package web import ( "net/http" - "os" - "path" - "runtime" "strings" "testing" @@ -13,15 +10,6 @@ import ( "github.com/jamiefdhurst/journal/test/mocks/database" ) -func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } -} - func TestNew_Run(t *testing.T) { db := &database.MockSqlite{} db.Result = &database.MockResult{} @@ -34,10 +22,21 @@ func TestNew_Run(t *testing.T) { controller := &New{} controller.DisableTracking() - // Display form + // Test forbidden when creation is disabled + container.Configuration.EnableCreate = false request, _ := http.NewRequest("GET", "/new", strings.NewReader("")) controller.Init(container, []string{"", "0"}, request) controller.Run(response, request) + if response.StatusCode != 404 || !strings.Contains(response.Content, "Page Not Found") { + t.Error("Expected error page when creation is disabled") + } + container.Configuration.EnableCreate = true + + // Display form + response.Reset() + request, _ = http.NewRequest("GET", "/new", strings.NewReader("")) + controller.Init(container, []string{"", "0"}, request) + controller.Run(response, request) if !strings.Contains(response.Content, "Total Posts\n
    2
    ") { + if !strings.Contains(response.Content, "Total Posts") || !strings.Contains(response.Content, "
    2
    ") { t.Error("Expected post count to be displayed") } - if !strings.Contains(response.Content, "
    First Post Date
    ") { + if !strings.Contains(response.Content, "First Post Date") { t.Error("Expected first post date to be displayed") } - if !strings.Contains(response.Content, "
    Posts Per Page
    \n
    25
    ") { + if !strings.Contains(response.Content, "Posts Per Page") || !strings.Contains(response.Content, "
    25
    ") { t.Error("Expected custom posts per page setting to be displayed") } - if !strings.Contains(response.Content, "
    Google Analytics
    \n
    Enabled
    ") { + if !strings.Contains(response.Content, "Google Analytics") || !strings.Contains(response.Content, "Enabled") { t.Error("Expected GA code to be displayed as enabled") } @@ -50,11 +50,11 @@ func TestStats_Run(t *testing.T) { db.Rows = &database.MockRowsEmpty{} controller.Run(response, request) - if !strings.Contains(response.Content, "
    Total Posts
    \n
    0
    ") { + if !strings.Contains(response.Content, "Total Posts") || !strings.Contains(response.Content, "
    0
    ") { t.Error("Expected post count to be 0") } - if !strings.Contains(response.Content, "
    First Post Date
    \n
    No posts yet
    ") { + if !strings.Contains(response.Content, "No posts yet") { t.Error("Expected 'No posts yet' message for first post date") } } diff --git a/internal/app/controller/web/view_test.go b/internal/app/controller/web/view_test.go index 40f7b30..45bea34 100644 --- a/internal/app/controller/web/view_test.go +++ b/internal/app/controller/web/view_test.go @@ -2,9 +2,6 @@ package web import ( "net/http" - "os" - "path" - "runtime" "strings" "testing" @@ -13,15 +10,6 @@ import ( "github.com/jamiefdhurst/journal/test/mocks/database" ) -func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } -} - func TestView_Run(t *testing.T) { db := &database.MockSqlite{} configuration := app.DefaultConfiguration() diff --git a/internal/app/model/journal_test.go b/internal/app/model/journal_test.go index e584187..4f13ef1 100644 --- a/internal/app/model/journal_test.go +++ b/internal/app/model/journal_test.go @@ -1,6 +1,7 @@ package model import ( + "strings" "testing" "time" @@ -71,6 +72,18 @@ func TestJournal_GetHTMLExcerpt(t *testing.T) { } } +func TestJournal_GetHTMLExcerpt_OuterBreak(t *testing.T) { + // First paragraph exhausts the word budget exactly — outer loop should break + j := Journal{Content: "word1 word2\n\nmore words here"} + result := j.GetHTMLExcerpt(2) + if !strings.Contains(result, "word1") { + t.Errorf("Expected first paragraph in excerpt, got: %s", result) + } + if strings.Contains(result, "more") { + t.Errorf("Expected second paragraph to be excluded, got: %s", result) + } +} + func TestJournal_GetHTMLExcerpt_ShortWords(t *testing.T) { tables := []struct { input string @@ -345,6 +358,12 @@ func TestJournals_Save(t *testing.T) { if journal.ID != 2 || journal.Title != "Testing 2" { t.Error("Expected same Journal to have been returned with new ID") } + + // Test with reserved slug — should be renamed + journal = js.Save(Journal{ID: 0, Slug: "random", Title: "Random"}) + if journal.Slug != "random-post" { + t.Errorf("Expected reserved slug to be renamed to 'random-post', got %q", journal.Slug) + } } func TestSlugify(t *testing.T) { @@ -367,6 +386,88 @@ func TestSlugify(t *testing.T) { } } +func TestJournal_GetHTML(t *testing.T) { + j := Journal{Content: "**Bold** and *italic*"} + result := j.GetHTML() + if !strings.Contains(result, "Bold") { + t.Errorf("Expected bold HTML tag in output, got: %s", result) + } + if !strings.Contains(result, "italic") { + t.Errorf("Expected italic HTML tag in output, got: %s", result) + } +} + +func TestJournals_FindRandom(t *testing.T) { + db := &database.MockSqlite{} + container := &app.Container{Db: db} + js := Journals{Container: container} + + // No entries — returns empty journal + db.Rows = &database.MockRowsEmpty{} + journal := js.FindRandom() + if journal.ID != 0 { + t.Error("Expected empty journal when no entries exist") + } + + // With entries — returns a valid journal + db.Rows = &database.MockJournal_MultipleRows{} + journal = js.FindRandom() + if journal.ID == 0 { + t.Error("Expected a journal entry to be returned when entries exist") + } +} + +func TestValidateSlug(t *testing.T) { + tables := []struct { + slug string + valid bool + }{ + {"random", false}, + {"new", false}, + {"stats", false}, + {"api/something", false}, + {"api/v1/post", false}, + {"normal-slug", true}, + {"hello-world-2025", true}, + {"my-journal-entry", true}, + } + + for _, table := range tables { + actual := ValidateSlug(table.slug) + if actual != table.valid { + t.Errorf("ValidateSlug(%q): expected %v, got %v", table.slug, table.valid, actual) + } + } +} + +func TestValidate(t *testing.T) { + tables := []struct { + title string + date string + content string + valid bool + }{ + {"", "2025-01-01", "Some content here", false}, // empty title + {"Ab", "2025-01-01", "Some content here", false}, // title too short + {"Valid Title", "", "Some content here", false}, // empty date + {"Valid Title", "not-a-date", "Some content here", false}, // bad date format + {"Valid Title", "2025-01-01", "", false}, // empty content + {"Valid Title", "2025-01-01", "Ab", false}, // content too short + {"Valid Title", "2025-01-01", "Some content here", true}, // valid + {"random", "2025-01-01", "Some content here", false}, // reserved slug + {"new", "2025-01-01", "Some content here", false}, // reserved slug + {"stats", "2025-01-01", "Some content here", false}, // reserved slug + } + + for _, table := range tables { + actual := Validate(table.title, table.date, table.content) + if actual != table.valid { + t.Errorf("Validate(%q, %q, %q): expected %v, got %v", + table.title, table.date, table.content, table.valid, actual) + } + } +} + func TestJournal_GetFormattedCreatedAt(t *testing.T) { // Test with nil timestamp j := Journal{} @@ -430,8 +531,6 @@ func TestJournals_Save_Timestamps(t *testing.T) { } // 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) diff --git a/internal/app/model/migration_test.go b/internal/app/model/migration_test.go index cf45cf7..ca7ac8b 100644 --- a/internal/app/model/migration_test.go +++ b/internal/app/model/migration_test.go @@ -1,111 +1,268 @@ package model import ( - "testing" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/pkg/markdown" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/pkg/markdown" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestMigrations_CreateTable(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - migrations := Migrations{Container: container} - migrations.CreateTable() - if db.Queries != 1 { - t.Errorf("Expected 1 query to have been run") - } + db := &database.MockSqlite{} + container := &app.Container{Db: db} + migrations := Migrations{Container: container} + migrations.CreateTable() + if db.Queries != 1 { + t.Errorf("Expected 1 query to have been run") + } } func TestMigrations_HasMigrationRun(t *testing.T) { - // Test error case - db := &database.MockSqlite{} - db.ErrorMode = true - container := &app.Container{Db: db} - migrations := Migrations{Container: container} - - if migrations.HasMigrationRun("test_migration") { - t.Error("Should return false when database has an error") - } - - // Test migration not found - db.ErrorMode = false - db.Rows = &database.MockRowsEmpty{} - - if migrations.HasMigrationRun("test_migration") { - t.Error("Should return false when migration doesn't exist") - } - - // Create a mock for testing with a found migration - db2 := &database.MockSqlite{} - db2.Rows = &database.MockRowsEmpty{} - container2 := &app.Container{Db: db2} - migrations2 := Migrations{Container: container2} - - // For this test, we'll just return a true value directly - // The real implementation would check if a record exists with applied=true - // but that's difficult to mock without modifying the mock objects - if migrations2.HasMigrationRun("test_migration") { - // This is just a placeholder test since we can't easily test the positive case - // without modifying the mock database objects - } + db := &database.MockSqlite{} + container := &app.Container{Db: db} + migrations := Migrations{Container: container} + + // Test error case + db.ErrorMode = true + if migrations.HasMigrationRun("test_migration") { + t.Error("Should return false when database has an error") + } + + // Test migration not found + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + if migrations.HasMigrationRun("test_migration") { + t.Error("Should return false when migration doesn't exist") + } + + // Test migration found and applied + db.Rows = &database.MockMigration_SingleRow{} + if !migrations.HasMigrationRun("test_migration") { + t.Error("Should return true when migration has been applied") + } } func TestMigrations_RecordMigration(t *testing.T) { - // Test error on query - db := &database.MockSqlite{} - db.ErrorMode = true - container := &app.Container{Db: db} - migrations := Migrations{Container: container} - - err := migrations.RecordMigration("test_migration") - if err == nil { - t.Error("Should return error when database has an error on query") - } - - // Test insert new migration - db.ErrorMode = false - db.Rows = &database.MockRowsEmpty{} - db.Result = &database.MockResult{} - - err = migrations.RecordMigration("test_migration") - if err != nil { - t.Errorf("Should not return error when inserting: %v", err) - } - - // Since we're working with mocks, we can't easily test the update path - // without significantly modifying the mock implementation + db := &database.MockSqlite{} + container := &app.Container{Db: db} + migrations := Migrations{Container: container} + + // Test error on initial query + db.ErrorMode = true + err := migrations.RecordMigration("test_migration") + if err == nil { + t.Error("Should return error when database has an error on query") + } + + // Test insert new migration (no existing record) + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + db.Result = &database.MockResult{} + err = migrations.RecordMigration("test_migration") + if err != nil { + t.Errorf("Should not return error when inserting: %v", err) + } + + // Test update existing migration record + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockMigration_SingleRow{}) + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.RecordMigration("test_migration") + if err != nil { + t.Errorf("Should not return error when updating: %v", err) + } + + // Test error on INSERT Exec + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // SELECT — no existing record + db.ErrorAtQuery = 2 // Fail the INSERT Exec + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.RecordMigration("test_migration") + if err == nil { + t.Error("Should return error when INSERT Exec fails") + } +} + +func TestMigrations_MigrateRandomSlugs(t *testing.T) { + // Test already-applied case + db := &database.MockSqlite{} + db.Rows = &database.MockMigration_SingleRow{} + container := &app.Container{Db: db} + migrations := Migrations{Container: container} + err := migrations.MigrateRandomSlugs() + if err != nil { + t.Errorf("Already-applied migration should return nil: %v", err) + } + if db.Queries != 1 { + t.Errorf("Expected only 1 query when migration already applied, got %d", db.Queries) + } + + // Test migration runs — no journal with "random" slug + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockRowsEmpty{}) // FindBySlug("random") — not found + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateRandomSlugs() + if err != nil { + t.Errorf("Migration should run without errors when no 'random' slug exists: %v", err) + } + + // Test migration runs — journal with "random" slug exists + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockJournal_SingleRow{}) // FindBySlug("random") — found + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateRandomSlugs() + if err != nil { + t.Errorf("Migration should run without errors when 'random' slug is found: %v", err) + } + + // Test RecordMigration failure path + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockRowsEmpty{}) // FindBySlug("random") — not found + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.ErrorAtQuery = 4 // Fail RecordMigration INSERT + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateRandomSlugs() + if err == nil { + t.Error("Expected error when RecordMigration fails") + } +} + +func TestMigrations_MigrateAddTimestamps(t *testing.T) { + // Test already-applied case + db := &database.MockSqlite{} + db.Rows = &database.MockMigration_SingleRow{} + container := &app.Container{Db: db} + migrations := Migrations{Container: container} + err := migrations.MigrateAddTimestamps() + if err != nil { + t.Errorf("Already-applied migration should return nil: %v", err) + } + if db.Queries != 1 { + t.Errorf("Expected only 1 query when migration already applied, got %d", db.Queries) + } + + // Test error on first ALTER TABLE (created_at) + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.ErrorAtQuery = 2 // Fail the first Exec (ALTER TABLE created_at) + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateAddTimestamps() + if err == nil { + t.Error("Expected error when first ALTER TABLE fails") + } + + // Test error on second ALTER TABLE (updated_at) + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.ErrorAtQuery = 3 // Fail the second Exec (ALTER TABLE updated_at) + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateAddTimestamps() + if err == nil { + t.Error("Expected error when second ALTER TABLE fails") + } + + // Test migration runs for first time + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateAddTimestamps() + if err != nil { + t.Errorf("Migration should run without errors: %v", err) + } + + // Test RecordMigration failure path + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.ErrorAtQuery = 5 // Fail RecordMigration INSERT (after 2 ALTERs) + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateAddTimestamps() + if err == nil { + t.Error("Expected error when RecordMigration fails") + } } func TestMigrations_MigrateHTMLToMarkdown(t *testing.T) { - // Setup mock database and container - db := &database.MockSqlite{} - db.EnableMultiMode() - - // Mock the migrations table query - show migration hasn't run yet - db.AppendResult(&database.MockRowsEmpty{}) - - // Mock the journal fetch query - db.AppendResult(&database.MockJournal_MultipleRows{}) - - // Mock the record migration queries - db.AppendResult(&database.MockRowsEmpty{}) - db.Result = &database.MockResult{} - - container := &app.Container{ - Db: db, - MarkdownProcessor: &markdown.Markdown{}, - } - - // Run the migration - migrations := Migrations{Container: container} - err := migrations.MigrateHTMLToMarkdown() - - if err != nil { - t.Errorf("Migration should run without errors: %v", err) - } - - // For testing the already-applied case, we'd need custom mock rows - // which is difficult with the current mock implementation -} \ No newline at end of file + // Test already-applied case (migration skipped) + db := &database.MockSqlite{} + db.Rows = &database.MockMigration_SingleRow{} + container := &app.Container{ + Db: db, + MarkdownProcessor: &markdown.Markdown{}, + } + migrations := Migrations{Container: container} + err := migrations.MigrateHTMLToMarkdown() + if err != nil { + t.Errorf("Already-applied migration should return nil: %v", err) + } + if db.Queries != 1 { + t.Errorf("Expected only 1 query (the check) when migration already applied, got %d", db.Queries) + } + + // Test migration runs for the first time + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun check + db.AppendResult(&database.MockJournal_MultipleRows{}) // FetchAll + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.Result = &database.MockResult{} + container = &app.Container{ + Db: db, + MarkdownProcessor: &markdown.Markdown{}, + } + migrations = Migrations{Container: container} + err = migrations.MigrateHTMLToMarkdown() + if err != nil { + t.Errorf("Migration should run without errors: %v", err) + } + + // Test RecordMigration failure path (no journals to migrate) + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockRowsEmpty{}) // FetchAll — no entries + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.ErrorAtQuery = 4 // Fail RecordMigration INSERT + db.Result = &database.MockResult{} + container = &app.Container{ + Db: db, + MarkdownProcessor: &markdown.Markdown{}, + } + migrations = Migrations{Container: container} + err = migrations.MigrateHTMLToMarkdown() + if err == nil { + t.Error("Expected error when RecordMigration fails") + } +} diff --git a/internal/app/model/visit_test.go b/internal/app/model/visit_test.go index 6d4778a..26101a5 100644 --- a/internal/app/model/visit_test.go +++ b/internal/app/model/visit_test.go @@ -44,6 +44,13 @@ func TestVisits_FindByDateAndURL(t *testing.T) { if emptyVisit.ID != 0 { t.Errorf("Expected empty visit ID to be 0, got %d", emptyVisit.ID) } + + // Test error case + db.ErrorMode = true + errorVisit := visits.FindByDateAndURL("2023-01-01", "/error") + if errorVisit.ID != 0 { + t.Errorf("Expected zero-value visit on error, got ID %d", errorVisit.ID) + } } func TestVisits_RecordVisit(t *testing.T) { @@ -75,7 +82,15 @@ func TestVisits_GetDailyStats(t *testing.T) { container := &app.Container{Db: db} visits := Visits{Container: container} + // Test error case + db.ErrorMode = true + errorStats := visits.GetDailyStats(14) + if len(errorStats) != 0 { + t.Errorf("Expected empty stats on error, got %d", len(errorStats)) + } + // Test with mock data + db.ErrorMode = false db.Rows = &database.MockVisitStats_DailyRows{} dailyStats := visits.GetDailyStats(14) @@ -99,7 +114,15 @@ func TestVisits_GetMonthlyStats(t *testing.T) { container := &app.Container{Db: db} visits := Visits{Container: container} + // Test error case + db.ErrorMode = true + errorStats := visits.GetMonthlyStats() + if len(errorStats) != 0 { + t.Errorf("Expected empty stats on error, got %d", len(errorStats)) + } + // Test with mock data + db.ErrorMode = false db.Rows = &database.MockVisitStats_MonthlyRows{} monthlyStats := visits.GetMonthlyStats() diff --git a/internal/app/router/router_test.go b/internal/app/router/router_test.go new file mode 100644 index 0000000..f1623b0 --- /dev/null +++ b/internal/app/router/router_test.go @@ -0,0 +1,26 @@ +package router + +import ( + "testing" + + "github.com/jamiefdhurst/journal/internal/app" +) + +func TestNewRouter(t *testing.T) { + config := app.DefaultConfiguration() + container := &app.Container{Configuration: config} + rtr := NewRouter(container) + + if rtr == nil { + t.Fatal("Expected router to be created, got nil") + } + if len(rtr.Routes) == 0 { + t.Error("Expected routes to be registered") + } + if rtr.ErrorController == nil { + t.Error("Expected error controller to be set") + } + if len(rtr.StaticPaths) == 0 { + t.Error("Expected static paths to be configured") + } +} diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go index 164a062..1ccf6b2 100644 --- a/pkg/controller/controller_test.go +++ b/pkg/controller/controller_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/jamiefdhurst/journal/internal/app" + mockCtrl "github.com/jamiefdhurst/journal/test/mocks/controller" + mockDb "github.com/jamiefdhurst/journal/test/mocks/database" ) type BlankInterface struct{} @@ -52,11 +54,84 @@ func TestInit(t *testing.T) { if len(controller.Params()) != 2 { t.Error("Expected params to be set") } - if controller.sessionStore == nil { - t.Error("Expected session store to be initialized") - } - if controller.session == nil { + if controller.Session() == nil { t.Error("Expected session to be initialized") } }) } + +func TestDisableTracking(t *testing.T) { + db := &mockDb.MockSqlite{} + db.Rows = &mockDb.MockRowsEmpty{} + db.Result = &mockDb.MockResult{} + container := &app.Container{ + Configuration: app.Configuration{ + SessionKey: "12345678901234567890123456789012", + SessionName: "test-session", + }, + Db: db, + } + c := Super{} + c.DisableTracking() + request, _ := http.NewRequest("GET", "/test-path", strings.NewReader("")) + c.Init(container, []string{}, request) + if db.Queries != 0 { + t.Errorf("Expected no DB queries when tracking disabled, got %d", db.Queries) + } +} + +func TestTrackVisit(t *testing.T) { + db := &mockDb.MockSqlite{} + db.Rows = &mockDb.MockRowsEmpty{} + db.Result = &mockDb.MockResult{} + container := &app.Container{ + Configuration: app.Configuration{ + SessionKey: "12345678901234567890123456789012", + SessionName: "test-session", + }, + Db: db, + } + c := Super{} + request, _ := http.NewRequest("GET", "/test-path", strings.NewReader("")) + c.Init(container, []string{}, request) + if db.Queries == 0 { + t.Error("Expected DB queries for visit tracking when not disabled") + } +} + +func TestTrackVisit_NilContainer(t *testing.T) { + c := Super{} + request, _ := http.NewRequest("GET", "/test-path", strings.NewReader("")) + // Should not panic with nil container + c.trackVisit(request) +} + +func TestSaveSession(t *testing.T) { + // Without a session store — should be a no-op + c := Super{} + response := mockCtrl.NewMockResponse() + c.SaveSession(response) + if response.Headers.Get("Set-Cookie") != "" { + t.Error("Expected no Set-Cookie header when there is no session store") + } + + // With a session store + container := &app.Container{ + Configuration: app.Configuration{ + SessionKey: "12345678901234567890123456789012", + SessionName: "test-session", + CookieMaxAge: 3600, + CookieHTTPOnly: true, + }, + } + c2 := Super{} + c2.DisableTracking() + request, _ := http.NewRequest("GET", "/", strings.NewReader("")) + c2.Init(container, []string{}, request) + c2.Session().Set("key", "value") + response2 := mockCtrl.NewMockResponse() + c2.SaveSession(response2) + if response2.Headers.Get("Set-Cookie") == "" { + t.Error("Expected Set-Cookie header after saving session") + } +} diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go index 137bd27..e10778a 100644 --- a/pkg/database/database_test.go +++ b/pkg/database/database_test.go @@ -1,9 +1,35 @@ package database import ( + "os" + "path/filepath" "testing" ) +func TestSqliteConnect_NewFile(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "new.db") + + sqlite := &Sqlite{} + err := sqlite.Connect(dbPath) + if err != nil { + t.Errorf("Expected successful connect to new file, got: %s", err) + } + sqlite.Close() + + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + t.Error("Expected database file to have been created") + } +} + +func TestSqliteConnect_Error(t *testing.T) { + sqlite := &Sqlite{} + err := sqlite.Connect("/nonexistent/directory/test.db") + if err == nil { + t.Error("Expected error when connecting to uncreateable path") + } +} + func TestSqliteClose(t *testing.T) { sqlite := &Sqlite{} _ = sqlite.Connect("../../test/data/test.db") diff --git a/pkg/database/pagination_test.go b/pkg/database/pagination_test.go index 59237d0..a35b0cb 100644 --- a/pkg/database/pagination_test.go +++ b/pkg/database/pagination_test.go @@ -4,6 +4,34 @@ import ( "testing" ) +func TestLinksPagination(t *testing.T) { + tables := []struct { + url string + info PaginationInformation + previous string + next string + }{ + // Single page — no links + {"/posts", PaginationInformation{1, 1, 20, 20}, "", ""}, + // First page of many — only next + {"/posts", PaginationInformation{1, 3, 20, 60}, "", "/posts?page=2"}, + // Last page — only previous + {"/posts", PaginationInformation{3, 3, 20, 60}, "/posts?page=2", ""}, + // Middle page — both links + {"/posts", PaginationInformation{2, 3, 20, 60}, "/posts?page=1", "/posts?page=3"}, + } + + for _, table := range tables { + links := LinksPagination(table.url, table.info) + if links.Previous != table.previous { + t.Errorf("LinksPagination(%v): expected Previous %q, got %q", table.info, table.previous, links.Previous) + } + if links.Next != table.next { + t.Errorf("LinksPagination(%v): expected Next %q, got %q", table.info, table.next, links.Next) + } + } +} + func TestDisplayPagination(t *testing.T) { tables := []struct { input PaginationInformation diff --git a/pkg/env/parser_test.go b/pkg/env/parser_test.go index eaa9388..c6da1a2 100644 --- a/pkg/env/parser_test.go +++ b/pkg/env/parser_test.go @@ -151,6 +151,14 @@ func TestParseNonExistentFile(t *testing.T) { } } +func TestParseInvalidPath(t *testing.T) { + // A path with a null byte is invalid on all platforms, returning non-IsNotExist error + _, err := Parse("/tmp/test\x00file") + if err == nil { + t.Error("Expected error for path with embedded null byte") + } +} + func TestUnquote(t *testing.T) { tests := []struct { input string diff --git a/pkg/session/session_test.go b/pkg/session/session_test.go new file mode 100644 index 0000000..0dbb8f5 --- /dev/null +++ b/pkg/session/session_test.go @@ -0,0 +1,72 @@ +package session + +import ( + "testing" +) + +func TestSession_AddFlash_GetFlash(t *testing.T) { + s := NewSession() + + // GetFlash on empty session returns nil + flashes := s.GetFlash() + if flashes != nil { + t.Errorf("Expected nil flashes on empty session, got %v", flashes) + } + + // AddFlash then GetFlash + s.AddFlash("error") + flashes = s.GetFlash() + if len(flashes) != 1 || flashes[0] != "error" { + t.Errorf("Expected one 'error' flash, got %v", flashes) + } + + // GetFlash consumes the value + flashes = s.GetFlash() + if flashes != nil { + t.Errorf("Expected flash to be consumed after first GetFlash, got %v", flashes) + } +} + +func TestSession_AddFlash_Multiple(t *testing.T) { + s := NewSession() + s.AddFlash("first") + s.AddFlash("second") + + flashes := s.GetFlash() + if len(flashes) != 2 { + t.Errorf("Expected 2 flashes, got %d", len(flashes)) + } + if flashes[0] != "first" || flashes[1] != "second" { + t.Errorf("Unexpected flash values: %v", flashes) + } +} + +func TestSession_Get_Missing(t *testing.T) { + s := NewSession() + v := s.Get("nonexistent") + if v != nil { + t.Errorf("Expected nil for missing key, got %v", v) + } +} + +func TestSession_Get_Present(t *testing.T) { + s := NewSession() + s.Set("key", "value") + v := s.Get("key") + if v != "value" { + t.Errorf("Expected 'value', got %v", v) + } +} + +func TestSession_Delete(t *testing.T) { + s := NewSession() + s.Set("key", "value") + s.Delete("key") + v := s.Get("key") + if v != nil { + t.Errorf("Expected nil after Delete, got %v", v) + } + + // Delete of nonexistent key should not panic + s.Delete("nonexistent") +} diff --git a/pkg/session/store_test.go b/pkg/session/store_test.go index 6bc980a..c9ccd7d 100644 --- a/pkg/session/store_test.go +++ b/pkg/session/store_test.go @@ -1,6 +1,7 @@ package session import ( + "encoding/base64" "net/http" "net/http/httptest" "testing" @@ -302,6 +303,52 @@ func TestGetSession(t *testing.T) { }) } +func TestDecrypt_AuthFailure(t *testing.T) { + // Valid base64 that decodes to >= nonce size (12 bytes) but is not a valid ciphertext + key := "12345678901234567890123456789012" + store, _ := NewDefaultStore(key, CookieConfig{Name: "test-session", HTTPOnly: true}) + + garbage := base64.URLEncoding.EncodeToString(make([]byte, 20)) + var out map[string]interface{} + err := store.decrypt(garbage, &out) + if err == nil { + t.Error("Expected error when decrypting invalid ciphertext") + } +} + +func TestEncrypt_UnencodableType(t *testing.T) { + key := "12345678901234567890123456789012" + store, _ := NewDefaultStore(key, CookieConfig{Name: "test-session", HTTPOnly: true}) + + // Channels cannot be gob-encoded; this should trigger the encode error path + _, err := store.encrypt(make(chan int)) + if err == nil { + t.Error("Expected error when encoding unregisterable type") + } +} + +func TestGetSession_CorruptCookie(t *testing.T) { + key := "12345678901234567890123456789012" + config := CookieConfig{Name: "test-session", MaxAge: 3600, HTTPOnly: true} + + store, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{Name: "test-session", Value: "!!notvalidbase64!!"}) + + session, err := store.Get(req) + if session == nil { + t.Error("Expected a new empty session to be returned on corrupt cookie") + } + // Session should be empty since the cookie could not be decrypted + if len(session.Values) != 0 { + t.Errorf("Expected empty session values after corrupt cookie, got %v", session.Values) + } +} + func TestSessionCaching(t *testing.T) { key := "12345678901234567890123456789012" config := CookieConfig{ diff --git a/test/mocks/controller/controller.go b/test/mocks/controller/controller.go index 9d198c7..a8c6aba 100644 --- a/test/mocks/controller/controller.go +++ b/test/mocks/controller/controller.go @@ -75,6 +75,6 @@ func (m *MockResponse) WriteHeader(statusCode int) { // NewMockResponse Make a mock response func NewMockResponse() *MockResponse { m := &MockResponse{} - m.Headers = make(http.Header) + m.Reset() return m } diff --git a/test/mocks/database/database.go b/test/mocks/database/database.go index f87c577..ea8524e 100644 --- a/test/mocks/database/database.go +++ b/test/mocks/database/database.go @@ -8,27 +8,6 @@ import ( "github.com/jamiefdhurst/journal/pkg/database/rows" ) -// MockDatabase Mock for model.Database -type MockDatabase struct{} - -// Close Mock the close method -func (m *MockDatabase) Close() {} - -// Connect Mock the connect method -func (m *MockDatabase) Connect(dbFile string) error { - return nil -} - -// Exec Mock empty exec -func (m *MockDatabase) Exec(sql string, args ...interface{}) (sql.Result, error) { - return nil, nil -} - -// Query Mock empty query -func (m *MockDatabase) Query(sql string, args ...interface{}) (rows.Rows, error) { - return nil, nil -} - // MockJournal_MultipleRows Mock multiple rows returned for a Journal type MockJournal_MultipleRows struct { MockRowsEmpty @@ -132,7 +111,7 @@ func (m *MockResult) LastInsertId() (int64, error) { // RowsAffected Mock the rows affected func (m *MockResult) RowsAffected() (int64, error) { - return 0, nil + return 1, nil } // MockRowsEmpty An empty row set @@ -223,7 +202,8 @@ func (m *MockSqlite) Query(sql string, args ...interface{}) (rows.Rows, error) { func (m *MockSqlite) inArgs(slice []interface{}) bool { for _, v := range slice { - if v.(string) == m.ExpectedArgument { + s, ok := v.(string) + if ok && s == m.ExpectedArgument { return true } } @@ -231,6 +211,9 @@ func (m *MockSqlite) inArgs(slice []interface{}) bool { } func (m *MockSqlite) popResult() rows.Rows { + if len(m.multiResults) == 0 { + return &MockRowsEmpty{} + } result := m.multiResults[0] if len(m.multiResults) > 1 { m.multiResults = m.multiResults[1:] @@ -241,6 +224,28 @@ func (m *MockSqlite) popResult() rows.Rows { return result } +// MockMigration_SingleRow Mock single row returned for a Migration (applied=true) +type MockMigration_SingleRow struct { + MockRowsEmpty + RowNumber int +} + +// Next Mock 1 row +func (m *MockMigration_SingleRow) Next() bool { + m.RowNumber++ + return m.RowNumber < 2 +} + +// Scan Return the migration data +func (m *MockMigration_SingleRow) Scan(dest ...interface{}) error { + if m.RowNumber == 1 { + *dest[0].(*int) = 1 + *dest[1].(*string) = "test_migration" + *dest[2].(*bool) = true + } + return nil +} + // MockVisit_SingleRow Mock single row returned for a Visit type MockVisit_SingleRow struct { MockRowsEmpty From e9005c47f14ae265b123c5734bad115b56e02472 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 8 Mar 2026 12:44:51 +0000 Subject: [PATCH 50/52] extract bootstrap into a separate testable piece of code --- Makefile | 6 +- cmd/journal/main.go | 203 ++++++++++++++++++--------------------- cmd/journal/main_test.go | 17 +++- 3 files changed, 114 insertions(+), 112 deletions(-) diff --git a/Makefile b/Makefile index 5308045..a1dc29a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test +.PHONY: build test coverage clean build: @go build -v -o journal ./cmd/journal @@ -7,5 +7,9 @@ test: @2>&1 go test -coverprofile=cover.out -coverpkg=./internal/...,./pkg/... -v ./... | go2xunit > tests.xml @gocov convert cover.out | gocov-xml > coverage.xml +coverage: + @go test -coverprofile=cover.out -coverpkg=./internal/...,./pkg/... ./... + @go tool cover -func=cover.out + clean: @rm -f journal \ No newline at end of file diff --git a/cmd/journal/main.go b/cmd/journal/main.go index b9b664e..a0b1a8c 100644 --- a/cmd/journal/main.go +++ b/cmd/journal/main.go @@ -1,123 +1,108 @@ package main import ( - "crypto/tls" - "fmt" - "log" - "net/http" - "os" - - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/internal/app/router" - "github.com/jamiefdhurst/journal/pkg/database" - "github.com/jamiefdhurst/journal/pkg/markdown" + "crypto/tls" + "fmt" + "log" + "net/http" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/internal/app/router" + "github.com/jamiefdhurst/journal/pkg/database" + "github.com/jamiefdhurst/journal/pkg/markdown" ) var container *app.Container = &app.Container{} func config() app.Configuration { - // Define default configuration - configuration := app.DefaultConfiguration() - app.ApplyEnvConfiguration(&configuration) - - if !configuration.EnableCreate { - log.Println("Post creating is disabled...") - } - if !configuration.EnableEdit { - log.Println("Post editing is disabled...") - } - - return configuration + configuration := app.DefaultConfiguration() + app.ApplyEnvConfiguration(&configuration) + + if !configuration.EnableCreate { + log.Println("Post creating is disabled...") + } + if !configuration.EnableEdit { + log.Println("Post editing is disabled...") + } + + return configuration } -func loadDatabase() func() { - container.Db = &database.Sqlite{} - - // Set up the markdown processor - container.MarkdownProcessor = &markdown.Markdown{} - - log.Printf("Loading DB from %s...\n", container.Configuration.DatabasePath) - if err := container.Db.Connect(container.Configuration.DatabasePath); err != nil { - log.Printf("Database error - please verify that the %s path is available and writeable.\nError: %s\n", container.Configuration.DatabasePath, err) - os.Exit(1) - } - - // Create needed tables - js := model.Journals{Container: container} - if err := js.CreateTable(); err != nil { - log.Printf("Error creating journal table: %s\n", err) - log.Panicln(err) - } - ms := model.Migrations{Container: container} - if err := ms.CreateTable(); err != nil { - log.Printf("Error creating migrations table: %s\n", err) - log.Panicln(err) - } - vs := model.Visits{Container: container} - if err := vs.CreateTable(); err != nil { - log.Printf("Error creating visits table: %s\n", err) - log.Panicln(err) - } - - // Run migrations - if err := ms.MigrateHTMLToMarkdown(); err != nil { - log.Printf("Error during HTML to Markdown migration: %s\n", err) - log.Panicln(err) - } - if err := ms.MigrateRandomSlugs(); err != nil { - 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() - } +func bootstrap(c *app.Container, db app.Database, mp app.MarkdownProcessor) (func(), error) { + c.Db = db + c.MarkdownProcessor = mp + + log.Printf("Loading DB from %s...\n", c.Configuration.DatabasePath) + if err := c.Db.Connect(c.Configuration.DatabasePath); err != nil { + return nil, fmt.Errorf("database connect: %w", err) + } + + js := model.Journals{Container: c} + if err := js.CreateTable(); err != nil { + return nil, fmt.Errorf("journal table: %w", err) + } + ms := model.Migrations{Container: c} + if err := ms.CreateTable(); err != nil { + return nil, fmt.Errorf("migrations table: %w", err) + } + vs := model.Visits{Container: c} + if err := vs.CreateTable(); err != nil { + return nil, fmt.Errorf("visits table: %w", err) + } + + if err := ms.MigrateHTMLToMarkdown(); err != nil { + return nil, fmt.Errorf("html to markdown migration: %w", err) + } + if err := ms.MigrateRandomSlugs(); err != nil { + return nil, fmt.Errorf("random slug migration: %w", err) + } + if err := ms.MigrateAddTimestamps(); err != nil { + return nil, fmt.Errorf("add timestamps migration: %w", err) + } + + return func() { c.Db.Close() }, nil } func main() { - const version = "0.9.6" - fmt.Printf("Journal v%s\n-------------------\n\n", version) - - configuration := config() - - // Create/define container - container.Configuration = configuration - container.Version = version - - closeFunc := loadDatabase() - defer closeFunc() - - router := router.NewRouter(container) - - var err error - var protocols http.Protocols - protocols.SetHTTP1(true) - protocols.SetHTTP2(true) - protocols.SetUnencryptedHTTP2(true) - server := &http.Server{ - Addr: ":" + configuration.Port, - Handler: router, - Protocols: &protocols, - TLSConfig: &tls.Config{ - MinVersion: tls.VersionTLS13, - }, - } - log.Printf("Ready and listening on port %s...\n", configuration.Port) - if configuration.SSLCertificate == "" { - err = router.StartAndServe(server) - } else { - log.Printf("Certificate: %s\n", configuration.SSLCertificate) - log.Printf("Certificate Key: %s\n", configuration.SSLKey) - log.Println("Serving with SSL enabled...") - err = router.StartAndServeTLS(server, configuration.SSLCertificate, configuration.SSLKey) - } - - if err != nil { - log.Fatal("Error reported: ", err) - } + const version = "0.9.6" + fmt.Printf("Journal v%s\n-------------------\n\n", version) + + configuration := config() + container.Configuration = configuration + container.Version = version + + closeFunc, err := bootstrap(container, &database.Sqlite{}, &markdown.Markdown{}) + if err != nil { + log.Fatalf("Setup failed: %s\n", err) + } + defer closeFunc() + + rtr := router.NewRouter(container) + + var protocols http.Protocols + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + protocols.SetUnencryptedHTTP2(true) + server := &http.Server{ + Addr: ":" + configuration.Port, + Handler: rtr, + Protocols: &protocols, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + }, + } + log.Printf("Ready and listening on port %s...\n", configuration.Port) + if configuration.SSLCertificate == "" { + err = rtr.StartAndServe(server) + } else { + log.Printf("Certificate: %s\n", configuration.SSLCertificate) + log.Printf("Certificate Key: %s\n", configuration.SSLKey) + log.Println("Serving with SSL enabled...") + err = rtr.StartAndServeTLS(server, configuration.SSLCertificate, configuration.SSLKey) + } + + if err != nil { + log.Fatal("Error reported: ", err) + } } diff --git a/cmd/journal/main_test.go b/cmd/journal/main_test.go index ea9e869..784e798 100644 --- a/cmd/journal/main_test.go +++ b/cmd/journal/main_test.go @@ -15,6 +15,7 @@ import ( "github.com/jamiefdhurst/journal/internal/app/model" "github.com/jamiefdhurst/journal/internal/app/router" "github.com/jamiefdhurst/journal/pkg/database" + "github.com/jamiefdhurst/journal/pkg/markdown" pkgrouter "github.com/jamiefdhurst/journal/pkg/router" ) @@ -74,12 +75,24 @@ func TestConfig(t *testing.T) { } } -func TestLoadDatabase(t *testing.T) { +func TestBootstrap(t *testing.T) { container.Configuration.DatabasePath = "test/data/test.db" - closeFunc := loadDatabase() + closeFunc, err := bootstrap(container, &database.Sqlite{}, &markdown.Markdown{}) + if err != nil { + t.Fatalf("Expected bootstrap to succeed, got: %s", err) + } closeFunc() } +func TestBootstrap_ConnectError(t *testing.T) { + c := &app.Container{Configuration: app.DefaultConfiguration()} + c.Configuration.DatabasePath = "/nonexistent/path/test.db" + _, err := bootstrap(c, &database.Sqlite{}, &markdown.Markdown{}) + if err == nil { + t.Error("Expected bootstrap to fail with invalid database path") + } +} + func TestApiv1List(t *testing.T) { fixtures(t) From 1028ce6b1c551f6847cc501e1a412c4db847ef88 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 8 Mar 2026 12:46:47 +0000 Subject: [PATCH 51/52] ensure all files are spaced not tabbed --- cmd/journal/main.go | 182 ++++---- internal/app/controller/apiv1/random_test.go | 62 +-- internal/app/controller/web/setup_test.go | 18 +- internal/app/model/migration_test.go | 458 +++++++++---------- internal/app/router/router_test.go | 34 +- pkg/session/session_test.go | 96 ++-- pkg/session/store_test.go | 38 +- 7 files changed, 444 insertions(+), 444 deletions(-) diff --git a/cmd/journal/main.go b/cmd/journal/main.go index a0b1a8c..6878100 100644 --- a/cmd/journal/main.go +++ b/cmd/journal/main.go @@ -1,108 +1,108 @@ package main import ( - "crypto/tls" - "fmt" - "log" - "net/http" - - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/internal/app/model" - "github.com/jamiefdhurst/journal/internal/app/router" - "github.com/jamiefdhurst/journal/pkg/database" - "github.com/jamiefdhurst/journal/pkg/markdown" + "crypto/tls" + "fmt" + "log" + "net/http" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/internal/app/router" + "github.com/jamiefdhurst/journal/pkg/database" + "github.com/jamiefdhurst/journal/pkg/markdown" ) var container *app.Container = &app.Container{} func config() app.Configuration { - configuration := app.DefaultConfiguration() - app.ApplyEnvConfiguration(&configuration) + configuration := app.DefaultConfiguration() + app.ApplyEnvConfiguration(&configuration) - if !configuration.EnableCreate { - log.Println("Post creating is disabled...") - } - if !configuration.EnableEdit { - log.Println("Post editing is disabled...") - } + if !configuration.EnableCreate { + log.Println("Post creating is disabled...") + } + if !configuration.EnableEdit { + log.Println("Post editing is disabled...") + } - return configuration + return configuration } func bootstrap(c *app.Container, db app.Database, mp app.MarkdownProcessor) (func(), error) { - c.Db = db - c.MarkdownProcessor = mp - - log.Printf("Loading DB from %s...\n", c.Configuration.DatabasePath) - if err := c.Db.Connect(c.Configuration.DatabasePath); err != nil { - return nil, fmt.Errorf("database connect: %w", err) - } - - js := model.Journals{Container: c} - if err := js.CreateTable(); err != nil { - return nil, fmt.Errorf("journal table: %w", err) - } - ms := model.Migrations{Container: c} - if err := ms.CreateTable(); err != nil { - return nil, fmt.Errorf("migrations table: %w", err) - } - vs := model.Visits{Container: c} - if err := vs.CreateTable(); err != nil { - return nil, fmt.Errorf("visits table: %w", err) - } - - if err := ms.MigrateHTMLToMarkdown(); err != nil { - return nil, fmt.Errorf("html to markdown migration: %w", err) - } - if err := ms.MigrateRandomSlugs(); err != nil { - return nil, fmt.Errorf("random slug migration: %w", err) - } - if err := ms.MigrateAddTimestamps(); err != nil { - return nil, fmt.Errorf("add timestamps migration: %w", err) - } - - return func() { c.Db.Close() }, nil + c.Db = db + c.MarkdownProcessor = mp + + log.Printf("Loading DB from %s...\n", c.Configuration.DatabasePath) + if err := c.Db.Connect(c.Configuration.DatabasePath); err != nil { + return nil, fmt.Errorf("database connect: %w", err) + } + + js := model.Journals{Container: c} + if err := js.CreateTable(); err != nil { + return nil, fmt.Errorf("journal table: %w", err) + } + ms := model.Migrations{Container: c} + if err := ms.CreateTable(); err != nil { + return nil, fmt.Errorf("migrations table: %w", err) + } + vs := model.Visits{Container: c} + if err := vs.CreateTable(); err != nil { + return nil, fmt.Errorf("visits table: %w", err) + } + + if err := ms.MigrateHTMLToMarkdown(); err != nil { + return nil, fmt.Errorf("html to markdown migration: %w", err) + } + if err := ms.MigrateRandomSlugs(); err != nil { + return nil, fmt.Errorf("random slug migration: %w", err) + } + if err := ms.MigrateAddTimestamps(); err != nil { + return nil, fmt.Errorf("add timestamps migration: %w", err) + } + + return func() { c.Db.Close() }, nil } func main() { - const version = "0.9.6" - fmt.Printf("Journal v%s\n-------------------\n\n", version) - - configuration := config() - container.Configuration = configuration - container.Version = version - - closeFunc, err := bootstrap(container, &database.Sqlite{}, &markdown.Markdown{}) - if err != nil { - log.Fatalf("Setup failed: %s\n", err) - } - defer closeFunc() - - rtr := router.NewRouter(container) - - var protocols http.Protocols - protocols.SetHTTP1(true) - protocols.SetHTTP2(true) - protocols.SetUnencryptedHTTP2(true) - server := &http.Server{ - Addr: ":" + configuration.Port, - Handler: rtr, - Protocols: &protocols, - TLSConfig: &tls.Config{ - MinVersion: tls.VersionTLS13, - }, - } - log.Printf("Ready and listening on port %s...\n", configuration.Port) - if configuration.SSLCertificate == "" { - err = rtr.StartAndServe(server) - } else { - log.Printf("Certificate: %s\n", configuration.SSLCertificate) - log.Printf("Certificate Key: %s\n", configuration.SSLKey) - log.Println("Serving with SSL enabled...") - err = rtr.StartAndServeTLS(server, configuration.SSLCertificate, configuration.SSLKey) - } - - if err != nil { - log.Fatal("Error reported: ", err) - } + const version = "0.9.6" + fmt.Printf("Journal v%s\n-------------------\n\n", version) + + configuration := config() + container.Configuration = configuration + container.Version = version + + closeFunc, err := bootstrap(container, &database.Sqlite{}, &markdown.Markdown{}) + if err != nil { + log.Fatalf("Setup failed: %s\n", err) + } + defer closeFunc() + + rtr := router.NewRouter(container) + + var protocols http.Protocols + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + protocols.SetUnencryptedHTTP2(true) + server := &http.Server{ + Addr: ":" + configuration.Port, + Handler: rtr, + Protocols: &protocols, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + }, + } + log.Printf("Ready and listening on port %s...\n", configuration.Port) + if configuration.SSLCertificate == "" { + err = rtr.StartAndServe(server) + } else { + log.Printf("Certificate: %s\n", configuration.SSLCertificate) + log.Printf("Certificate Key: %s\n", configuration.SSLKey) + log.Println("Serving with SSL enabled...") + err = rtr.StartAndServeTLS(server, configuration.SSLCertificate, configuration.SSLKey) + } + + if err != nil { + log.Fatal("Error reported: ", err) + } } diff --git a/internal/app/controller/apiv1/random_test.go b/internal/app/controller/apiv1/random_test.go index fd947a5..b2d2741 100644 --- a/internal/app/controller/apiv1/random_test.go +++ b/internal/app/controller/apiv1/random_test.go @@ -1,43 +1,43 @@ package apiv1 import ( - "net/http" - "strings" - "testing" + "net/http" + "strings" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/test/mocks/controller" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestRandom_Run(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - random := &Random{} - random.DisableTracking() + db := &database.MockSqlite{} + container := &app.Container{Db: db} + random := &Random{} + random.DisableTracking() - // Test with a journal found - response := controller.NewMockResponse() - db.Rows = &database.MockJournal_SingleRow{} - request, _ := http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) - random.Init(container, []string{}, request) - random.Run(response, request) + // Test with a journal found + response := controller.NewMockResponse() + db.Rows = &database.MockJournal_SingleRow{} + request, _ := http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) + random.Init(container, []string{}, request) + random.Run(response, request) - if response.Headers.Get("Content-Type") != "application/json" { - t.Errorf("Expected application/json content type, got %s", response.Headers.Get("Content-Type")) - } - if !strings.Contains(response.Content, `"url"`) || !strings.Contains(response.Content, `"Title"`) { - t.Errorf("Expected JSON response with journal data, got: %s", response.Content) - } + if response.Headers.Get("Content-Type") != "application/json" { + t.Errorf("Expected application/json content type, got %s", response.Headers.Get("Content-Type")) + } + if !strings.Contains(response.Content, `"url"`) || !strings.Contains(response.Content, `"Title"`) { + t.Errorf("Expected JSON response with journal data, got: %s", response.Content) + } - // Test with no journal found - response = controller.NewMockResponse() - db.Rows = &database.MockRowsEmpty{} - request, _ = http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) - random.Init(container, []string{}, request) - random.Run(response, request) + // Test with no journal found + response = controller.NewMockResponse() + db.Rows = &database.MockRowsEmpty{} + request, _ = http.NewRequest("GET", "/api/v1/post/random", strings.NewReader("")) + random.Init(container, []string{}, request) + random.Run(response, request) - if response.StatusCode != http.StatusNotFound { - t.Errorf("Expected 404 not found, got status %d", response.StatusCode) - } + if response.StatusCode != http.StatusNotFound { + t.Errorf("Expected 404 not found, got status %d", response.StatusCode) + } } diff --git a/internal/app/controller/web/setup_test.go b/internal/app/controller/web/setup_test.go index 7aed309..ef9b331 100644 --- a/internal/app/controller/web/setup_test.go +++ b/internal/app/controller/web/setup_test.go @@ -1,16 +1,16 @@ package web import ( - "os" - "path" - "runtime" + "os" + "path" + "runtime" ) func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../../../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "../../../..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } } diff --git a/internal/app/model/migration_test.go b/internal/app/model/migration_test.go index ca7ac8b..8086c2f 100644 --- a/internal/app/model/migration_test.go +++ b/internal/app/model/migration_test.go @@ -1,268 +1,268 @@ package model import ( - "testing" + "testing" - "github.com/jamiefdhurst/journal/internal/app" - "github.com/jamiefdhurst/journal/pkg/markdown" - "github.com/jamiefdhurst/journal/test/mocks/database" + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/pkg/markdown" + "github.com/jamiefdhurst/journal/test/mocks/database" ) func TestMigrations_CreateTable(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - migrations := Migrations{Container: container} - migrations.CreateTable() - if db.Queries != 1 { - t.Errorf("Expected 1 query to have been run") - } + db := &database.MockSqlite{} + container := &app.Container{Db: db} + migrations := Migrations{Container: container} + migrations.CreateTable() + if db.Queries != 1 { + t.Errorf("Expected 1 query to have been run") + } } func TestMigrations_HasMigrationRun(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - migrations := Migrations{Container: container} + db := &database.MockSqlite{} + container := &app.Container{Db: db} + migrations := Migrations{Container: container} - // Test error case - db.ErrorMode = true - if migrations.HasMigrationRun("test_migration") { - t.Error("Should return false when database has an error") - } + // Test error case + db.ErrorMode = true + if migrations.HasMigrationRun("test_migration") { + t.Error("Should return false when database has an error") + } - // Test migration not found - db.ErrorMode = false - db.Rows = &database.MockRowsEmpty{} - if migrations.HasMigrationRun("test_migration") { - t.Error("Should return false when migration doesn't exist") - } + // Test migration not found + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + if migrations.HasMigrationRun("test_migration") { + t.Error("Should return false when migration doesn't exist") + } - // Test migration found and applied - db.Rows = &database.MockMigration_SingleRow{} - if !migrations.HasMigrationRun("test_migration") { - t.Error("Should return true when migration has been applied") - } + // Test migration found and applied + db.Rows = &database.MockMigration_SingleRow{} + if !migrations.HasMigrationRun("test_migration") { + t.Error("Should return true when migration has been applied") + } } func TestMigrations_RecordMigration(t *testing.T) { - db := &database.MockSqlite{} - container := &app.Container{Db: db} - migrations := Migrations{Container: container} + db := &database.MockSqlite{} + container := &app.Container{Db: db} + migrations := Migrations{Container: container} - // Test error on initial query - db.ErrorMode = true - err := migrations.RecordMigration("test_migration") - if err == nil { - t.Error("Should return error when database has an error on query") - } + // Test error on initial query + db.ErrorMode = true + err := migrations.RecordMigration("test_migration") + if err == nil { + t.Error("Should return error when database has an error on query") + } - // Test insert new migration (no existing record) - db.ErrorMode = false - db.Rows = &database.MockRowsEmpty{} - db.Result = &database.MockResult{} - err = migrations.RecordMigration("test_migration") - if err != nil { - t.Errorf("Should not return error when inserting: %v", err) - } + // Test insert new migration (no existing record) + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + db.Result = &database.MockResult{} + err = migrations.RecordMigration("test_migration") + if err != nil { + t.Errorf("Should not return error when inserting: %v", err) + } - // Test update existing migration record - db = &database.MockSqlite{} - db.EnableMultiMode() - db.AppendResult(&database.MockMigration_SingleRow{}) - db.Result = &database.MockResult{} - container = &app.Container{Db: db} - migrations = Migrations{Container: container} - err = migrations.RecordMigration("test_migration") - if err != nil { - t.Errorf("Should not return error when updating: %v", err) - } + // Test update existing migration record + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockMigration_SingleRow{}) + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.RecordMigration("test_migration") + if err != nil { + t.Errorf("Should not return error when updating: %v", err) + } - // Test error on INSERT Exec - db = &database.MockSqlite{} - db.EnableMultiMode() - db.AppendResult(&database.MockRowsEmpty{}) // SELECT — no existing record - db.ErrorAtQuery = 2 // Fail the INSERT Exec - container = &app.Container{Db: db} - migrations = Migrations{Container: container} - err = migrations.RecordMigration("test_migration") - if err == nil { - t.Error("Should return error when INSERT Exec fails") - } + // Test error on INSERT Exec + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // SELECT — no existing record + db.ErrorAtQuery = 2 // Fail the INSERT Exec + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.RecordMigration("test_migration") + if err == nil { + t.Error("Should return error when INSERT Exec fails") + } } func TestMigrations_MigrateRandomSlugs(t *testing.T) { - // Test already-applied case - db := &database.MockSqlite{} - db.Rows = &database.MockMigration_SingleRow{} - container := &app.Container{Db: db} - migrations := Migrations{Container: container} - err := migrations.MigrateRandomSlugs() - if err != nil { - t.Errorf("Already-applied migration should return nil: %v", err) - } - if db.Queries != 1 { - t.Errorf("Expected only 1 query when migration already applied, got %d", db.Queries) - } + // Test already-applied case + db := &database.MockSqlite{} + db.Rows = &database.MockMigration_SingleRow{} + container := &app.Container{Db: db} + migrations := Migrations{Container: container} + err := migrations.MigrateRandomSlugs() + if err != nil { + t.Errorf("Already-applied migration should return nil: %v", err) + } + if db.Queries != 1 { + t.Errorf("Expected only 1 query when migration already applied, got %d", db.Queries) + } - // Test migration runs — no journal with "random" slug - db = &database.MockSqlite{} - db.EnableMultiMode() - db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun - db.AppendResult(&database.MockRowsEmpty{}) // FindBySlug("random") — not found - db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT - db.Result = &database.MockResult{} - container = &app.Container{Db: db} - migrations = Migrations{Container: container} - err = migrations.MigrateRandomSlugs() - if err != nil { - t.Errorf("Migration should run without errors when no 'random' slug exists: %v", err) - } + // Test migration runs — no journal with "random" slug + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockRowsEmpty{}) // FindBySlug("random") — not found + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateRandomSlugs() + if err != nil { + t.Errorf("Migration should run without errors when no 'random' slug exists: %v", err) + } - // Test migration runs — journal with "random" slug exists - db = &database.MockSqlite{} - db.EnableMultiMode() - db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun - db.AppendResult(&database.MockJournal_SingleRow{}) // FindBySlug("random") — found - db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT - db.Result = &database.MockResult{} - container = &app.Container{Db: db} - migrations = Migrations{Container: container} - err = migrations.MigrateRandomSlugs() - if err != nil { - t.Errorf("Migration should run without errors when 'random' slug is found: %v", err) - } + // Test migration runs — journal with "random" slug exists + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockJournal_SingleRow{}) // FindBySlug("random") — found + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateRandomSlugs() + if err != nil { + t.Errorf("Migration should run without errors when 'random' slug is found: %v", err) + } - // Test RecordMigration failure path - db = &database.MockSqlite{} - db.EnableMultiMode() - db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun - db.AppendResult(&database.MockRowsEmpty{}) // FindBySlug("random") — not found - db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT - db.ErrorAtQuery = 4 // Fail RecordMigration INSERT - db.Result = &database.MockResult{} - container = &app.Container{Db: db} - migrations = Migrations{Container: container} - err = migrations.MigrateRandomSlugs() - if err == nil { - t.Error("Expected error when RecordMigration fails") - } + // Test RecordMigration failure path + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockRowsEmpty{}) // FindBySlug("random") — not found + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.ErrorAtQuery = 4 // Fail RecordMigration INSERT + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateRandomSlugs() + if err == nil { + t.Error("Expected error when RecordMigration fails") + } } func TestMigrations_MigrateAddTimestamps(t *testing.T) { - // Test already-applied case - db := &database.MockSqlite{} - db.Rows = &database.MockMigration_SingleRow{} - container := &app.Container{Db: db} - migrations := Migrations{Container: container} - err := migrations.MigrateAddTimestamps() - if err != nil { - t.Errorf("Already-applied migration should return nil: %v", err) - } - if db.Queries != 1 { - t.Errorf("Expected only 1 query when migration already applied, got %d", db.Queries) - } + // Test already-applied case + db := &database.MockSqlite{} + db.Rows = &database.MockMigration_SingleRow{} + container := &app.Container{Db: db} + migrations := Migrations{Container: container} + err := migrations.MigrateAddTimestamps() + if err != nil { + t.Errorf("Already-applied migration should return nil: %v", err) + } + if db.Queries != 1 { + t.Errorf("Expected only 1 query when migration already applied, got %d", db.Queries) + } - // Test error on first ALTER TABLE (created_at) - db = &database.MockSqlite{} - db.EnableMultiMode() - db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun - db.ErrorAtQuery = 2 // Fail the first Exec (ALTER TABLE created_at) - container = &app.Container{Db: db} - migrations = Migrations{Container: container} - err = migrations.MigrateAddTimestamps() - if err == nil { - t.Error("Expected error when first ALTER TABLE fails") - } + // Test error on first ALTER TABLE (created_at) + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.ErrorAtQuery = 2 // Fail the first Exec (ALTER TABLE created_at) + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateAddTimestamps() + if err == nil { + t.Error("Expected error when first ALTER TABLE fails") + } - // Test error on second ALTER TABLE (updated_at) - db = &database.MockSqlite{} - db.EnableMultiMode() - db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun - db.ErrorAtQuery = 3 // Fail the second Exec (ALTER TABLE updated_at) - db.Result = &database.MockResult{} - container = &app.Container{Db: db} - migrations = Migrations{Container: container} - err = migrations.MigrateAddTimestamps() - if err == nil { - t.Error("Expected error when second ALTER TABLE fails") - } + // Test error on second ALTER TABLE (updated_at) + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.ErrorAtQuery = 3 // Fail the second Exec (ALTER TABLE updated_at) + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateAddTimestamps() + if err == nil { + t.Error("Expected error when second ALTER TABLE fails") + } - // Test migration runs for first time - db = &database.MockSqlite{} - db.EnableMultiMode() - db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun - db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT - db.Result = &database.MockResult{} - container = &app.Container{Db: db} - migrations = Migrations{Container: container} - err = migrations.MigrateAddTimestamps() - if err != nil { - t.Errorf("Migration should run without errors: %v", err) - } + // Test migration runs for first time + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateAddTimestamps() + if err != nil { + t.Errorf("Migration should run without errors: %v", err) + } - // Test RecordMigration failure path - db = &database.MockSqlite{} - db.EnableMultiMode() - db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun - db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT - db.ErrorAtQuery = 5 // Fail RecordMigration INSERT (after 2 ALTERs) - db.Result = &database.MockResult{} - container = &app.Container{Db: db} - migrations = Migrations{Container: container} - err = migrations.MigrateAddTimestamps() - if err == nil { - t.Error("Expected error when RecordMigration fails") - } + // Test RecordMigration failure path + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.ErrorAtQuery = 5 // Fail RecordMigration INSERT (after 2 ALTERs) + db.Result = &database.MockResult{} + container = &app.Container{Db: db} + migrations = Migrations{Container: container} + err = migrations.MigrateAddTimestamps() + if err == nil { + t.Error("Expected error when RecordMigration fails") + } } func TestMigrations_MigrateHTMLToMarkdown(t *testing.T) { - // Test already-applied case (migration skipped) - db := &database.MockSqlite{} - db.Rows = &database.MockMigration_SingleRow{} - container := &app.Container{ - Db: db, - MarkdownProcessor: &markdown.Markdown{}, - } - migrations := Migrations{Container: container} - err := migrations.MigrateHTMLToMarkdown() - if err != nil { - t.Errorf("Already-applied migration should return nil: %v", err) - } - if db.Queries != 1 { - t.Errorf("Expected only 1 query (the check) when migration already applied, got %d", db.Queries) - } + // Test already-applied case (migration skipped) + db := &database.MockSqlite{} + db.Rows = &database.MockMigration_SingleRow{} + container := &app.Container{ + Db: db, + MarkdownProcessor: &markdown.Markdown{}, + } + migrations := Migrations{Container: container} + err := migrations.MigrateHTMLToMarkdown() + if err != nil { + t.Errorf("Already-applied migration should return nil: %v", err) + } + if db.Queries != 1 { + t.Errorf("Expected only 1 query (the check) when migration already applied, got %d", db.Queries) + } - // Test migration runs for the first time - db = &database.MockSqlite{} - db.EnableMultiMode() - db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun check - db.AppendResult(&database.MockJournal_MultipleRows{}) // FetchAll - db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT - db.Result = &database.MockResult{} - container = &app.Container{ - Db: db, - MarkdownProcessor: &markdown.Markdown{}, - } - migrations = Migrations{Container: container} - err = migrations.MigrateHTMLToMarkdown() - if err != nil { - t.Errorf("Migration should run without errors: %v", err) - } + // Test migration runs for the first time + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun check + db.AppendResult(&database.MockJournal_MultipleRows{}) // FetchAll + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.Result = &database.MockResult{} + container = &app.Container{ + Db: db, + MarkdownProcessor: &markdown.Markdown{}, + } + migrations = Migrations{Container: container} + err = migrations.MigrateHTMLToMarkdown() + if err != nil { + t.Errorf("Migration should run without errors: %v", err) + } - // Test RecordMigration failure path (no journals to migrate) - db = &database.MockSqlite{} - db.EnableMultiMode() - db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun - db.AppendResult(&database.MockRowsEmpty{}) // FetchAll — no entries - db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT - db.ErrorAtQuery = 4 // Fail RecordMigration INSERT - db.Result = &database.MockResult{} - container = &app.Container{ - Db: db, - MarkdownProcessor: &markdown.Markdown{}, - } - migrations = Migrations{Container: container} - err = migrations.MigrateHTMLToMarkdown() - if err == nil { - t.Error("Expected error when RecordMigration fails") - } + // Test RecordMigration failure path (no journals to migrate) + db = &database.MockSqlite{} + db.EnableMultiMode() + db.AppendResult(&database.MockRowsEmpty{}) // HasMigrationRun + db.AppendResult(&database.MockRowsEmpty{}) // FetchAll — no entries + db.AppendResult(&database.MockRowsEmpty{}) // RecordMigration SELECT + db.ErrorAtQuery = 4 // Fail RecordMigration INSERT + db.Result = &database.MockResult{} + container = &app.Container{ + Db: db, + MarkdownProcessor: &markdown.Markdown{}, + } + migrations = Migrations{Container: container} + err = migrations.MigrateHTMLToMarkdown() + if err == nil { + t.Error("Expected error when RecordMigration fails") + } } diff --git a/internal/app/router/router_test.go b/internal/app/router/router_test.go index f1623b0..3e91fb1 100644 --- a/internal/app/router/router_test.go +++ b/internal/app/router/router_test.go @@ -1,26 +1,26 @@ package router import ( - "testing" + "testing" - "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app" ) func TestNewRouter(t *testing.T) { - config := app.DefaultConfiguration() - container := &app.Container{Configuration: config} - rtr := NewRouter(container) + config := app.DefaultConfiguration() + container := &app.Container{Configuration: config} + rtr := NewRouter(container) - if rtr == nil { - t.Fatal("Expected router to be created, got nil") - } - if len(rtr.Routes) == 0 { - t.Error("Expected routes to be registered") - } - if rtr.ErrorController == nil { - t.Error("Expected error controller to be set") - } - if len(rtr.StaticPaths) == 0 { - t.Error("Expected static paths to be configured") - } + if rtr == nil { + t.Fatal("Expected router to be created, got nil") + } + if len(rtr.Routes) == 0 { + t.Error("Expected routes to be registered") + } + if rtr.ErrorController == nil { + t.Error("Expected error controller to be set") + } + if len(rtr.StaticPaths) == 0 { + t.Error("Expected static paths to be configured") + } } diff --git a/pkg/session/session_test.go b/pkg/session/session_test.go index 0dbb8f5..734fd20 100644 --- a/pkg/session/session_test.go +++ b/pkg/session/session_test.go @@ -1,72 +1,72 @@ package session import ( - "testing" + "testing" ) func TestSession_AddFlash_GetFlash(t *testing.T) { - s := NewSession() + s := NewSession() - // GetFlash on empty session returns nil - flashes := s.GetFlash() - if flashes != nil { - t.Errorf("Expected nil flashes on empty session, got %v", flashes) - } + // GetFlash on empty session returns nil + flashes := s.GetFlash() + if flashes != nil { + t.Errorf("Expected nil flashes on empty session, got %v", flashes) + } - // AddFlash then GetFlash - s.AddFlash("error") - flashes = s.GetFlash() - if len(flashes) != 1 || flashes[0] != "error" { - t.Errorf("Expected one 'error' flash, got %v", flashes) - } + // AddFlash then GetFlash + s.AddFlash("error") + flashes = s.GetFlash() + if len(flashes) != 1 || flashes[0] != "error" { + t.Errorf("Expected one 'error' flash, got %v", flashes) + } - // GetFlash consumes the value - flashes = s.GetFlash() - if flashes != nil { - t.Errorf("Expected flash to be consumed after first GetFlash, got %v", flashes) - } + // GetFlash consumes the value + flashes = s.GetFlash() + if flashes != nil { + t.Errorf("Expected flash to be consumed after first GetFlash, got %v", flashes) + } } func TestSession_AddFlash_Multiple(t *testing.T) { - s := NewSession() - s.AddFlash("first") - s.AddFlash("second") + s := NewSession() + s.AddFlash("first") + s.AddFlash("second") - flashes := s.GetFlash() - if len(flashes) != 2 { - t.Errorf("Expected 2 flashes, got %d", len(flashes)) - } - if flashes[0] != "first" || flashes[1] != "second" { - t.Errorf("Unexpected flash values: %v", flashes) - } + flashes := s.GetFlash() + if len(flashes) != 2 { + t.Errorf("Expected 2 flashes, got %d", len(flashes)) + } + if flashes[0] != "first" || flashes[1] != "second" { + t.Errorf("Unexpected flash values: %v", flashes) + } } func TestSession_Get_Missing(t *testing.T) { - s := NewSession() - v := s.Get("nonexistent") - if v != nil { - t.Errorf("Expected nil for missing key, got %v", v) - } + s := NewSession() + v := s.Get("nonexistent") + if v != nil { + t.Errorf("Expected nil for missing key, got %v", v) + } } func TestSession_Get_Present(t *testing.T) { - s := NewSession() - s.Set("key", "value") - v := s.Get("key") - if v != "value" { - t.Errorf("Expected 'value', got %v", v) - } + s := NewSession() + s.Set("key", "value") + v := s.Get("key") + if v != "value" { + t.Errorf("Expected 'value', got %v", v) + } } func TestSession_Delete(t *testing.T) { - s := NewSession() - s.Set("key", "value") - s.Delete("key") - v := s.Get("key") - if v != nil { - t.Errorf("Expected nil after Delete, got %v", v) - } + s := NewSession() + s.Set("key", "value") + s.Delete("key") + v := s.Get("key") + if v != nil { + t.Errorf("Expected nil after Delete, got %v", v) + } - // Delete of nonexistent key should not panic - s.Delete("nonexistent") + // Delete of nonexistent key should not panic + s.Delete("nonexistent") } diff --git a/pkg/session/store_test.go b/pkg/session/store_test.go index c9ccd7d..9542dd2 100644 --- a/pkg/session/store_test.go +++ b/pkg/session/store_test.go @@ -328,25 +328,25 @@ func TestEncrypt_UnencodableType(t *testing.T) { } func TestGetSession_CorruptCookie(t *testing.T) { - key := "12345678901234567890123456789012" - config := CookieConfig{Name: "test-session", MaxAge: 3600, HTTPOnly: true} - - store, err := NewDefaultStore(key, config) - if err != nil { - t.Fatalf("Failed to create store: %v", err) - } - - req := httptest.NewRequest("GET", "/", nil) - req.AddCookie(&http.Cookie{Name: "test-session", Value: "!!notvalidbase64!!"}) - - session, err := store.Get(req) - if session == nil { - t.Error("Expected a new empty session to be returned on corrupt cookie") - } - // Session should be empty since the cookie could not be decrypted - if len(session.Values) != 0 { - t.Errorf("Expected empty session values after corrupt cookie, got %v", session.Values) - } + key := "12345678901234567890123456789012" + config := CookieConfig{Name: "test-session", MaxAge: 3600, HTTPOnly: true} + + store, err := NewDefaultStore(key, config) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{Name: "test-session", Value: "!!notvalidbase64!!"}) + + session, err := store.Get(req) + if session == nil { + t.Error("Expected a new empty session to be returned on corrupt cookie") + } + // Session should be empty since the cookie could not be decrypted + if len(session.Values) != 0 { + t.Errorf("Expected empty session values after corrupt cookie, got %v", session.Values) + } } func TestSessionCaching(t *testing.T) { From 953394a7b7446988755b4f89e4bf476112c745de Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 8 Mar 2026 20:43:11 +0000 Subject: [PATCH 52/52] Added guides for installing and using the journal --- README.md | 6 + docs/installation.md | 310 +++++++++++++++++++++++++++++++++++++++++++ docs/user-guide.md | 142 ++++++++++++++++++++ 3 files changed, 458 insertions(+) create mode 100644 docs/installation.md create mode 100644 docs/user-guide.md diff --git a/README.md b/README.md index cf647a2..ce1978f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ It makes use of a SQLite database to store the journal entries. [API Documentation](api/README.md) - also available via `openapi.yml` as a URL when deployed. +[Installation Guide](docs/installation.md) - binary and Docker installation +with configuration reference. + +[User Guide](docs/user-guide.md) - creating and editing entries, and +navigating the journal. + ## Purpose Journal serves as an easy-to-read and simple Golang program for new developers diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..c1c1ba1 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,310 @@ +# Installation Guide + +This guide covers how to install and configure Journal for self-hosting. + +## Prerequisites + +Journal stores its data in a SQLite database file. Before running the +application, decide where you want that file to live on the host — you will +need to make that path available to the process. + +--- + +## Option 1: Binary (Linux x86-64) + +Pre-built binaries for Linux x86-64 are attached to every +[GitHub release](https://github.com/jamiefdhurst/journal/releases). + +### Download and run + +Replace `` with the release you want (e.g. `1.0.0`): + +```bash +# Download the binary +curl -L -o journal \ + https://github.com/jamiefdhurst/journal/releases/download/v/journal-bin_linux_x64-v + +# Make it executable +chmod +x journal + +# Run it +./journal +``` + +The application listens on port `3000` by default. Open +`http://localhost:3000` in your browser. + +### Persistent data + +By default Journal writes its SQLite database to `$GOPATH/data/journal.db`. +Set `J_DB_PATH` to an absolute path to store it wherever you like: + +```bash +J_DB_PATH=/var/lib/journal/journal.db ./journal +``` + +### Running as a service (systemd) + +```ini +# /etc/systemd/system/journal.service +[Unit] +Description=Journal +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/journal +Restart=on-failure +Environment=J_DB_PATH=/var/lib/journal/journal.db +Environment=J_TITLE=My Journal + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now journal +``` + +--- + +## Option 2: Docker / Container Runtime + +A Docker image is published to the GitHub Container Registry on every release: + +``` +ghcr.io/jamiefdhurst/journal:latest +ghcr.io/jamiefdhurst/journal:v +``` + +### Docker + +```bash +docker run -d \ + --name journal \ + -p 3000:3000 \ + -v /var/lib/journal:/go/data \ + ghcr.io/jamiefdhurst/journal:latest +``` + +Pass configuration via `-e` flags: + +```bash +docker run -d \ + --name journal \ + -p 3000:3000 \ + -v /var/lib/journal:/go/data \ + -e J_TITLE="My Journal" \ + -e J_DESCRIPTION="A place for my thoughts" \ + -e J_CREATE=1 \ + -e J_EDIT=1 \ + ghcr.io/jamiefdhurst/journal:latest +``` + +### Docker Compose + +```yaml +services: + journal: + image: ghcr.io/jamiefdhurst/journal:latest + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - journal_data:/go/data + environment: + J_TITLE: My Journal + J_DESCRIPTION: A place for my thoughts + J_CREATE: "1" + J_EDIT: "1" + J_SESSION_KEY: "a-random-32-character-string-here" + +volumes: + journal_data: +``` + +```bash +docker compose up -d +``` + +### Podman + +```bash +podman run -d \ + --name journal \ + -p 3000:3000 \ + -v /var/lib/journal:/go/data:Z \ + ghcr.io/jamiefdhurst/journal:latest +``` + +--- + +## Configuration + +All configuration is done through environment variables. You can also place +them in a `.env` file in the working directory — the application reads this +file on startup before any environment variables are applied. + +### Example `.env` file + +```env +J_TITLE=My Journal +J_DESCRIPTION=A place for my thoughts +J_PORT=3000 +J_DB_PATH=/var/lib/journal/journal.db +J_CREATE=1 +J_EDIT=1 +J_SESSION_KEY=a-random-32-character-string-here +``` + +### General + +| Variable | Description | Default | +|---|---|---| +| `J_TITLE` | Title displayed in the journal UI | _(empty)_ | +| `J_DESCRIPTION` | HTML description shown in the journal UI | _(empty)_ | +| `J_PORT` | HTTP port to listen on | `3000` | +| `J_DB_PATH` | Path to the SQLite database file | `$GOPATH/data/journal.db` | +| `J_CREATE` | Set to `0` to disable creating new entries | _(enabled)_ | +| `J_EDIT` | Set to `0` to disable editing entries | _(enabled)_ | +| `J_POSTS_PER_PAGE` | Number of entries shown per page | `20` | +| `J_EXCERPT_WORDS` | Word count for entry previews on the index | `50` | +| `J_THEME` | Theme name from the `web/themes/` folder | `default` | +| `J_GA_CODE` | Google Analytics tag (e.g. `UA-XXXXX-X`) — omit to disable | _(disabled)_ | + +### SSL/TLS + +| Variable | Description | +|---|---| +| `J_SSL_CERT` | Path to PEM certificate file. Setting this enables HTTPS. | +| `J_SSL_KEY` | Path to PEM private key file. | + +When `J_SSL_CERT` is set, session cookies automatically gain the `Secure` +flag so they are never sent over plain HTTP. + +### Session and Cookie Security + +| Variable | Description | Default | +|---|---|---| +| `J_SESSION_KEY` | 32-character ASCII encryption key for session data (AES-256). If unset, a random key is generated each startup — sessions will not survive restarts. | _(random)_ | +| `J_SESSION_NAME` | Name of the session cookie | `journal-session` | +| `J_COOKIE_DOMAIN` | Restricts cookies to a specific domain | _(current domain)_ | +| `J_COOKIE_MAX_AGE` | Cookie lifetime in seconds | `2592000` (30 days) | +| `J_COOKIE_HTTPONLY` | Set to `0` to allow JavaScript access to cookies (not recommended) | `true` | + +> **Security tip:** Always set `J_SESSION_KEY` to a stable, randomly-generated +> 32-character string in production so that user sessions survive application +> restarts. You can generate one with: +> +> ```bash +> LC_ALL=C tr -dc 'A-Za-z0-9' ``` + +--- + +## Putting it behind a reverse proxy + +It is recommended to run Journal behind a reverse proxy such as Nginx or +Caddy so that you can terminate TLS centrally and use a standard port. + +### Caddy example + +``` +journal.example.com { + reverse_proxy localhost:3000 +} +``` + +Caddy will obtain and renew a TLS certificate automatically. + +### Nginx example + +```nginx +server { + listen 443 ssl; + server_name journal.example.com; + + ssl_certificate /etc/ssl/certs/journal.crt; + ssl_certificate_key /etc/ssl/private/journal.key; + + location / { + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +#### Making your journal private with HTTP basic authentication + +If you want to restrict access to your journal, Nginx can prompt visitors for +a username and password before forwarding requests to the application. + +1. Install the `apache2-utils` package (Debian/Ubuntu) or `httpd-tools` + (RHEL/Fedora) to get the `htpasswd` command: + + ```bash + sudo apt install apache2-utils # Debian/Ubuntu + sudo dnf install httpd-tools # RHEL/Fedora + ``` + +2. Create a password file and add a user: + + ```bash + sudo htpasswd -c /etc/nginx/.htpasswd yourname + ``` + + To add more users later, omit the `-c` flag (it would overwrite the file): + + ```bash + sudo htpasswd /etc/nginx/.htpasswd anotheruser + ``` + +3. Add `auth_basic` directives to your server block: + + ```nginx + server { + listen 443 ssl; + server_name journal.example.com; + + ssl_certificate /etc/ssl/certs/journal.crt; + ssl_certificate_key /etc/ssl/private/journal.key; + + location / { + auth_basic "Journal"; + auth_basic_user_file /etc/nginx/.htpasswd; + + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } + ``` + +4. Reload Nginx to apply the change: + + ```bash + sudo nginx -t && sudo systemctl reload nginx + ``` + +Visitors will now see a browser login prompt before they can access any page. +If you want to keep the API public while protecting the web UI, use separate +`location` blocks: + +```nginx +location /api/ { + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; +} + +location / { + auth_basic "Journal"; + auth_basic_user_file /etc/nginx/.htpasswd; + + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; +} +``` diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..bcc0752 --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,142 @@ +# User Guide + +This guide covers the day-to-day use of Journal: browsing entries, creating +new posts, editing existing ones, and navigating the built-in views. + +--- + +## The index + +When you open Journal in your browser you land on the index page. It lists +your most recent entries in reverse-chronological order, showing a short +excerpt from each one. Use the pagination links at the bottom of the page to +move between pages; the number of entries per page is controlled by the +`J_POSTS_PER_PAGE` configuration variable (default: 20). + +Each entry on the index shows: + +- **Title** — click it to open the full entry +- **Date** — the date the entry is written for +- **Excerpt** — the first few words of the content (default: 50 words) +- **Edit** button — only visible when editing is enabled + +--- + +## Reading an entry + +Click an entry title or its **Read More** link to open the full post. The +content is written in Markdown and rendered as HTML. At the bottom of the +entry you will find links to navigate to the **previous** and **next** +entries. + +Each entry URL follows the pattern `/`, where the slug is derived +automatically from the title when the post is created (for example, a title +of "My First Post" becomes `/my-first-post`). + +--- + +## Creating an entry + +> Creating entries must be enabled. If the **New Entry** link is not +> visible, the `J_CREATE` variable has been set to `0`. + +1. Click **New Entry** in the navigation (or go to `/new` directly). +2. Fill in the three fields: + - **Title** — required, at least 3 characters. + - **Date** — required, in `YYYY-MM-DD` format. The date picker pre-fills + today's date. + - **Content** — required, at least 3 characters. Write in + [Markdown](https://www.markdownguide.org/basic-syntax/) — headings, + bold, italic, lists, links, code blocks and blockquotes are all + supported. +3. Click **Save**. + +On success you are redirected to the index, which shows a confirmation +message. The new entry appears at the top of the list. + +### How slugs are generated + +The URL slug is derived from the title automatically: + +- Characters that are not letters or numbers are replaced with `-` +- Everything is lowercased +- If another entry already has the same slug, a numeric suffix is appended + (e.g. `-2`, `-3`) + +You cannot set the slug manually through the UI. + +### Validation rules + +The form will reject a submission if: + +- The title is missing or fewer than 3 characters +- The date is missing or not in `YYYY-MM-DD` format +- The content is missing or fewer than 3 characters +- The slug derived from the title conflicts with a reserved path (`new`, + `random`, `stats`) or the API prefix (`api/`) + +--- + +## Editing an entry + +> Editing entries must be enabled. If the **Edit** button is not visible, +> the `J_EDIT` variable has been set to `0`. + +1. From the index or the entry view, click **Edit**. +2. Modify the **Title**, **Date**, or **Content** as needed. +3. Click **Save**. + +The same validation rules apply as when creating. Saving an edit updates the +entry's last-modified timestamp but does not change its URL slug. + +--- + +## The calendar + +Navigate to `/calendar` to see a month-by-month calendar view of your +entries. Days that have at least one entry show the entry titles as links. + +Use the navigation arrows to move between months and years: + +- `/calendar` — current month +- `/calendar/2024` — all months in 2024 +- `/calendar/2024/03` — March 2024 + +--- + +## Random entry + +Go to `/random` (or click **Random** in the navigation if your theme +includes it) to jump to a randomly selected entry. This is useful for +rediscovering older writing. + +--- + +## Stats + +The `/stats` page shows an overview of your journal: + +- **Total posts** and the date of the first post +- Current **configuration** (title, description, theme, posts per page, + whether create/edit are enabled) +- **Daily visits** for the last 14 days, broken down by web and API hits +- **Monthly visits** for the full history + +--- + +## Markdown reference + +Entry content is written in Markdown. A quick reference: + +| Format | Markdown | +|---|---| +| Heading | `# H1`, `## H2`, `### H3` | +| Bold | `**bold**` | +| Italic | `*italic*` | +| Link | `[text](https://example.com)` | +| Unordered list | `- item` | +| Ordered list | `1. item` | +| Inline code | `` `code` `` | +| Code block | ```` ```language ```` … ```` ``` ```` | +| Blockquote | `> quote` | +| Horizontal rule | `---` |