"===t)return e.execCommand("outdent",!1,t);if((f.isFF||f.isEdge)&&"p"===t)return Array.prototype.slice.call(n.childNodes).some(function(e){return!f.isBlockContainer(e)})&&e.execCommand("formatBlock",!1,t),e.execCommand("outdent",!1,t)}return e.execCommand("formatBlock",!1,t)},setTargetBlank:function(e,t){var n,i=t||!1;if("a"===e.nodeName.toLowerCase())e.target="_blank",e.rel="noopener noreferrer";else for(e=e.getElementsByTagName("a"),n=0;n=l&&e.start<=s&&(m||e.start=l&&e.end<=s&&(e.trailingImageCount?u=!0:(o.setEnd(r,e.end-l),h=!0)),l=s;h||(r=a.pop())}!c&&f&&(o.setStart(f,f.length),o.setEnd(f,f.length)),void 0!==e.emptyBlocksIndex&&(o=this.importSelectionMoveCursorPastBlocks(n,t,e.emptyBlocksIndex,o)),i&&(o=this.importSelectionMoveCursorPastAnchor(e,o)),this.selectRange(n,o)}},importSelectionMoveCursorPastAnchor:function(e,t){if(e.start===e.end&&3===t.startContainer.nodeType&&t.startOffset===t.startContainer.nodeValue.length&&b.util.traverseUp(t.startContainer,function(e){return"a"===e.nodeName.toLowerCase()})){for(var n=t.startContainer,i=t.startContainer.parentNode;null!==i&&"a"!==i.nodeName.toLowerCase();)i=i.childNodes[i.childNodes.length-1]!==n?null:(n=i).parentNode;if(null!==i&&"a"===i.nodeName.toLowerCase()){for(var o=null,s=0;null===o&&s=l&&t.start<=s?!0:c)&&t.end>=l&&t.end<=s&&(u=!0),l=s;d||(r=a.pop())}return h},selectionContainsContent:function(e){var e=e.getSelection();return!(!e||e.isCollapsed||!e.rangeCount||""===e.toString().trim()&&(!(e=this.getSelectedParentElement(e.getRangeAt(0)))||!("img"===e.nodeName.toLowerCase()||1===e.nodeType&&e.querySelector("img"))))},selectionInContentEditableFalse:function(e){var n,e=this.findMatchingSelectionParent(function(e){var t=e&&e.getAttribute("contenteditable");return"true"===t&&(n=!0),"#text"!==e.nodeName&&"false"===t},e);return!n&&e},getSelectionHtml:function(e){var t,n,i,o="",s=e.getSelection();if(s.rangeCount){for(i=e.createElement("div"),t=0,n=s.rangeCount;tB",contentFA:''},italic:{name:"italic",action:"italic",aria:"italic",tagNames:["i","em"],style:{prop:"font-style",value:"italic"},useQueryState:!0,contentDefault:"I",contentFA:''},underline:{name:"underline",action:"underline",aria:"underline",tagNames:["u"],style:{prop:"text-decoration",value:"underline"},useQueryState:!0,contentDefault:"U",contentFA:''},strikethrough:{name:"strikethrough",action:"strikethrough",aria:"strike through",tagNames:["strike"],style:{prop:"text-decoration",value:"line-through"},useQueryState:!0,contentDefault:"A",contentFA:''},superscript:{name:"superscript",action:"superscript",aria:"superscript",tagNames:["sup"],contentDefault:"x1",contentFA:''},subscript:{name:"subscript",action:"subscript",aria:"subscript",tagNames:["sub"],contentDefault:"x1",contentFA:''},image:{name:"image",action:"image",aria:"image",tagNames:["img"],contentDefault:"image",contentFA:''},html:{name:"html",action:"html",aria:"evaluate html",tagNames:["iframe","object"],contentDefault:"html",contentFA:''},orderedlist:{name:"orderedlist",action:"insertorderedlist",aria:"ordered list",tagNames:["ol"],useQueryState:!0,contentDefault:"1.",contentFA:''},unorderedlist:{name:"unorderedlist",action:"insertunorderedlist",aria:"unordered list",tagNames:["ul"],useQueryState:!0,contentDefault:"•",contentFA:''},indent:{name:"indent",action:"indent",aria:"indent",tagNames:[],contentDefault:"→",contentFA:''},outdent:{name:"outdent",action:"outdent",aria:"outdent",tagNames:[],contentDefault:"←",contentFA:''},justifyCenter:{name:"justifyCenter",action:"justifyCenter",aria:"center justify",tagNames:[],style:{prop:"text-align",value:"center"},contentDefault:"C",contentFA:''},justifyFull:{name:"justifyFull",action:"justifyFull",aria:"full justify",tagNames:[],style:{prop:"text-align",value:"justify"},contentDefault:"J",contentFA:''},justifyLeft:{name:"justifyLeft",action:"justifyLeft",aria:"left justify",tagNames:[],style:{prop:"text-align",value:"left"},contentDefault:"L",contentFA:''},justifyRight:{name:"justifyRight",action:"justifyRight",aria:"right justify",tagNames:[],style:{prop:"text-align",value:"right"},contentDefault:"R",contentFA:''},removeFormat:{name:"removeFormat",aria:"remove formatting",action:"removeFormat",contentDefault:"X",contentFA:''},quote:{name:"quote",action:"append-blockquote",aria:"blockquote",tagNames:["blockquote"],contentDefault:"“",contentFA:''},pre:{name:"pre",action:"append-pre",aria:"preformatted text",tagNames:["pre"],contentDefault:"0101",contentFA:''},h1:{name:"h1",action:"append-h1",aria:"header type one",tagNames:["h1"],contentDefault:"H1",contentFA:'1'},h2:{name:"h2",action:"append-h2",aria:"header type two",tagNames:["h2"],contentDefault:"H2",contentFA:'2'},h3:{name:"h3",action:"append-h3",aria:"header type three",tagNames:["h3"],contentDefault:"H3",contentFA:'3'},h4:{name:"h4",action:"append-h4",aria:"header type four",tagNames:["h4"],contentDefault:"H4",contentFA:'4'},h5:{name:"h5",action:"append-h5",aria:"header type five",tagNames:["h5"],contentDefault:"H5",contentFA:'5'},h6:{name:"h6",action:"append-h6",aria:"header type six",tagNames:["h6"],contentDefault:"H6",contentFA:'6'}},h=b.extensions.button.extend({init:function(){b.extensions.button.prototype.init.apply(this,arguments)},formSaveLabel:"✓",formCloseLabel:"×",activeClass:"medium-editor-toolbar-form-active",hasForm:!0,getForm:function(){},isDisplayed:function(){return!!this.hasForm&&this.getForm().classList.contains(this.activeClass)},showForm:function(){this.hasForm&&this.getForm().classList.add(this.activeClass)},hideForm:function(){this.hasForm&&this.getForm().classList.remove(this.activeClass)},showToolbarDefaultActions:function(){var e=this.base.getExtensionByName("toolbar");e&&e.showToolbarDefaultActions()},hideToolbarDefaultActions:function(){var e=this.base.getExtensionByName("toolbar");e&&e.hideToolbarDefaultActions()},setToolbarPosition:function(){var e=this.base.getExtensionByName("toolbar");e&&e.setToolbarPosition()}}),b.extensions.form=h,m=b.extensions.form.extend({customClassOption:null,customClassOptionText:"Button",linkValidation:!1,placeholderText:"Paste or type a link",targetCheckbox:!1,targetCheckboxText:"Open in new window",name:"anchor",action:"createLink",aria:"link",tagNames:["a"],contentDefault:"#",contentFA:'',init:function(){b.extensions.form.prototype.init.apply(this,arguments),this.subscribe("editableKeydown",this.handleKeydown.bind(this))},handleClick:function(e){e.preventDefault(),e.stopPropagation();e=b.selection.getSelectionRange(this.document);return"a"===e.startContainer.nodeName.toLowerCase()||"a"===e.endContainer.nodeName.toLowerCase()||b.util.getClosestTag(b.selection.getSelectedParentElement(e),"a")?this.execAction("unlink"):(this.isDisplayed()||this.showForm(),!1)},handleKeydown:function(e){b.util.isKey(e,b.util.keyCode.K)&&b.util.isMetaCtrlKey(e)&&!e.shiftKey&&this.handleClick(e)},getForm:function(){return this.form||(this.form=this.createForm()),this.form},getTemplate:function(){var e=[''];return e.push('',"fontawesome"===this.getEditorOption("buttonLabels")?'':this.formSaveLabel,""),e.push('',"fontawesome"===this.getEditorOption("buttonLabels")?'':this.formCloseLabel,""),this.targetCheckbox&&e.push('
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, "
February
2019") || !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}}
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":"
"}]}`
// 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":"
"}`
// 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":"
"`, `"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 @@
"`, `"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, "
February
2018") || !strings.Contains(response.Content, "
February
2019") || !strings.Contains(response.Content, "
January
2019") || !strings.Contains(response.Content, "
January
Edit 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, "