diff --git a/cmd/web/handlers_guestbook_test.go b/cmd/web/handlers_guestbook_test.go index 5ee8aeb..3faaa03 100644 --- a/cmd/web/handlers_guestbook_test.go +++ b/cmd/web/handlers_guestbook_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "net/http" "testing" @@ -17,3 +18,43 @@ func TestPing(t *testing.T) { assert.Equal(t, code, http.StatusOK) assert.Equal(t, body, "OK") } + +func TestGetGuestbookView(t *testing.T) { + app := newTestApplication(t) + ts := newTestServer(t, app.routes()) + defer ts.Close() + + tests := []struct { + name string + urlPath string + wantCode int + wantBody string + }{ + { + name: "Valid id", + urlPath: fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(1)), + wantCode: http.StatusOK, + wantBody: "Guestbook for Example", + }, + { + name: "Non-existent ID", + urlPath: fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(2)), + wantCode: http.StatusNotFound, + }, + { + name: "String ID", + urlPath: "/websites/abcd/guestbook", + wantCode: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, _, body := ts.get(t, tt.urlPath) + assert.Equal(t, code, tt.wantCode) + if tt.wantBody != "" { + assert.StringContains(t, body, tt.wantBody) + } + }) + } +} diff --git a/cmd/web/handlers_user_test.go b/cmd/web/handlers_user_test.go new file mode 100644 index 0000000..8a97224 --- /dev/null +++ b/cmd/web/handlers_user_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "net/http" + "net/url" + "testing" + + "git.32bit.cafe/32bitcafe/guestbook/internal/assert" +) + +func TestUserSignup(t *testing.T) { + app := newTestApplication(t) + ts := newTestServer(t, app.routes()) + defer ts.Close() + + _, _, body := ts.get(t, "/users/register") + validCSRFToken := extractCSRFToken(t, body) + + const ( + validName = "John" + validPassword = "validPassword" + validEmail = "john@example.com" + formTag = `
` + ) + + tests := []struct { + name string + userName string + userEmail string + userPassword string + csrfToken string + wantCode int + wantFormTag string + }{ + { + name: "Valid submission", + userName: validName, + userEmail: validEmail, + userPassword: validPassword, + csrfToken: validCSRFToken, + wantCode: http.StatusSeeOther, + }, + { + name: "Missing token", + userName: validName, + userEmail: validEmail, + userPassword: validPassword, + wantCode: http.StatusBadRequest, + }, + { + name: "Empty name", + userName: "", + userEmail: validEmail, + userPassword: validPassword, + csrfToken: validCSRFToken, + wantCode: http.StatusUnprocessableEntity, + wantFormTag: formTag, + }, + { + name: "Empty email", + userName: validName, + userEmail: "", + userPassword: validPassword, + csrfToken: validCSRFToken, + wantCode: http.StatusUnprocessableEntity, + wantFormTag: formTag, + }, + { + name: "Empty password", + userName: validName, + userEmail: validEmail, + userPassword: "", + csrfToken: validCSRFToken, + wantCode: http.StatusUnprocessableEntity, + wantFormTag: formTag, + }, + { + name: "Invalid email", + userName: validName, + userEmail: "asdfasdf", + userPassword: validPassword, + csrfToken: validCSRFToken, + wantCode: http.StatusUnprocessableEntity, + wantFormTag: formTag, + }, + { + name: "Invalid password", + userName: validName, + userEmail: validEmail, + userPassword: "asdfasd", + csrfToken: validCSRFToken, + wantCode: http.StatusUnprocessableEntity, + wantFormTag: formTag, + }, + { + name: "Duplicate email", + userName: validName, + userEmail: "dupe@example.com", + userPassword: validPassword, + csrfToken: validCSRFToken, + wantCode: http.StatusUnprocessableEntity, + wantFormTag: formTag, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(*testing.T) { + form := url.Values{} + form.Add("username", tt.userName) + form.Add("email", tt.userEmail) + form.Add("password", tt.userPassword) + form.Add("csrf_token", tt.csrfToken) + code, _, body := ts.postForm(t, "/users/register", form) + assert.Equal(t, code, tt.wantCode) + if tt.wantFormTag != "" { + assert.StringContains(t, body, tt.wantFormTag) + } + }) + } + +} diff --git a/cmd/web/testutils_test.go b/cmd/web/testutils_test.go index ca542a6..2aad5e0 100644 --- a/cmd/web/testutils_test.go +++ b/cmd/web/testutils_test.go @@ -2,17 +2,39 @@ package main import ( "bytes" + "html" "io" "log/slog" "net/http" "net/http/cookiejar" "net/http/httptest" + "net/url" + "regexp" "testing" + "time" + + "git.32bit.cafe/32bitcafe/guestbook/internal/models/mocks" + "github.com/alexedwards/scs/v2" + "github.com/gorilla/schema" ) func newTestApplication(t *testing.T) *application { + formDecoder := schema.NewDecoder() + formDecoder.IgnoreUnknownKeys(true) + + sessionManager := scs.New() + sessionManager.Lifetime = 12 * time.Hour + sessionManager.Cookie.Secure = true + return &application{ - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + sessionManager: sessionManager, + websites: &mocks.WebsiteModel{}, + guestbooks: &mocks.GuestbookModel{}, + users: &mocks.UserModel{}, + guestbookComments: &mocks.GuestbookCommentModel{}, + formDecoder: formDecoder, + timezones: getAvailableTimezones(), } } @@ -48,3 +70,28 @@ func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, strin return rs.StatusCode, rs.Header, string(body) } + +func (ts *testServer) postForm(t *testing.T, urlPath string, form url.Values) (int, http.Header, string) { + rs, err := ts.Client().PostForm(ts.URL+urlPath, form) + if err != nil { + t.Fatal(err) + } + + defer rs.Body.Close() + body, err := io.ReadAll(rs.Body) + if err != nil { + t.Fatal(err) + } + body = bytes.TrimSpace(body) + return rs.StatusCode, rs.Header, string(body) +} + +var csrfTokenRX = regexp.MustCompile(``) + +func extractCSRFToken(t *testing.T, body string) string { + matches := csrfTokenRX.FindStringSubmatch(body) + if len(matches) < 2 { + t.Fatal("no csrf token found in body") + } + return html.UnescapeString(matches[1]) +} diff --git a/internal/assert/assert.go b/internal/assert/assert.go new file mode 100644 index 0000000..5f6c938 --- /dev/null +++ b/internal/assert/assert.go @@ -0,0 +1,22 @@ +package assert + +import ( + "strings" + "testing" +) + +func Equal[T comparable](t *testing.T, actual, expected T) { + t.Helper() + + if actual != expected { + t.Errorf("got: %v; want %v", actual, expected) + } +} + +func StringContains(t *testing.T, actual, expectedSubstring string) { + t.Helper() + + if !strings.Contains(actual, expectedSubstring) { + t.Errorf("got: %q; expected to contain: %q", actual, expectedSubstring) + } +} diff --git a/internal/models/guestbookcomment.go b/internal/models/guestbookcomment.go index d071adf..4a7203b 100644 --- a/internal/models/guestbookcomment.go +++ b/internal/models/guestbookcomment.go @@ -24,6 +24,15 @@ type GuestbookCommentModel struct { DB *sql.DB } +type GuestbookCommentModelInterface interface { + Insert(shortId uint64, guestbookId, parentId int64, authorName, authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error) + Get(shortId uint64) (GuestbookComment, error) + GetAll(guestbookId int64) ([]GuestbookComment, error) + GetDeleted(guestbookId int64) ([]GuestbookComment, error) + GetUnpublished(guestbookId int64) ([]GuestbookComment, error) + UpdateComment(comment *GuestbookComment) error +} + func (m *GuestbookCommentModel) Insert(shortId uint64, guestbookId, parentId int64, authorName, authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error) { stmt := `INSERT INTO guestbook_comments (ShortId, GuestbookId, ParentId, AuthorName, diff --git a/internal/models/mocks/guestbook.go b/internal/models/mocks/guestbook.go new file mode 100644 index 0000000..fcde0a3 --- /dev/null +++ b/internal/models/mocks/guestbook.go @@ -0,0 +1,66 @@ +package mocks + +import ( + "time" + + "git.32bit.cafe/32bitcafe/guestbook/internal/models" +) + +var mockGuestbook = models.Guestbook{ + ID: 1, + ShortId: 1, + UserId: 1, + WebsiteId: 1, + Created: time.Now(), + IsActive: true, + Settings: models.GuestbookSettings{ + IsCommentingEnabled: true, + IsVisible: true, + FilteredWords: make([]string, 0), + AllowRemoteHostAccess: true, + }, +} + +type GuestbookModel struct{} + +func (m *GuestbookModel) InitializeSettingsMap() error { + return nil +} + +func (m *GuestbookModel) Insert(shortId uint64, userId int64, websiteId int64, settings models.GuestbookSettings) (int64, error) { + return 2, nil +} + +func (m *GuestbookModel) Get(shortId uint64) (models.Guestbook, error) { + switch shortId { + case 1: + return mockGuestbook, nil + default: + return models.Guestbook{}, models.ErrNoRecord + } +} + +func (m *GuestbookModel) GetAll(userId int64) ([]models.Guestbook, error) { + switch userId { + case 1: + return []models.Guestbook{mockGuestbook}, nil + default: + return []models.Guestbook{}, models.ErrNoRecord + } +} + +func (m *GuestbookModel) UpdateGuestbookSettings(guestbookId int64, settings models.GuestbookSettings) error { + return nil +} + +func (m *GuestbookModel) UpdateSetting(guestbookId int64, setting models.Setting, value string) error { + return nil +} + +func (m *GuestbookModel) AddFilteredWord(guestbookId int64, word string) error { + return nil +} + +func (m *GuestbookModel) RemoveFilteredWord(guestbookId int64, word string) error { + return nil +} diff --git a/internal/models/mocks/guestbookcomment.go b/internal/models/mocks/guestbookcomment.go new file mode 100644 index 0000000..6a0c972 --- /dev/null +++ b/internal/models/mocks/guestbookcomment.go @@ -0,0 +1,64 @@ +package mocks + +import ( + "time" + + "git.32bit.cafe/32bitcafe/guestbook/internal/models" +) + +var mockGuestbookComment = models.GuestbookComment{ + ID: 1, + ShortId: 1, + GuestbookId: 1, + AuthorName: "John Test", + AuthorEmail: "test@example.com", + AuthorSite: "example.com", + CommentText: "Hello, world", + Created: time.Now(), + IsPublished: true, +} + +type GuestbookCommentModel struct{} + +func (m *GuestbookCommentModel) Insert(shortId uint64, guestbookId, parentId int64, authorName, + authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error) { + return 2, nil +} + +func (m *GuestbookCommentModel) Get(shortId uint64) (models.GuestbookComment, error) { + switch shortId { + case 1: + return mockGuestbookComment, nil + default: + return models.GuestbookComment{}, models.ErrNoRecord + } +} + +func (m *GuestbookCommentModel) GetAll(guestbookId int64) ([]models.GuestbookComment, error) { + switch guestbookId { + case 1: + return []models.GuestbookComment{mockGuestbookComment}, nil + case 2: + return []models.GuestbookComment{}, nil + default: + return []models.GuestbookComment{}, models.ErrNoRecord + } +} + +func (m *GuestbookCommentModel) GetDeleted(guestbookId int64) ([]models.GuestbookComment, error) { + switch guestbookId { + default: + return []models.GuestbookComment{}, models.ErrNoRecord + } +} + +func (m *GuestbookCommentModel) GetUnpublished(guestbookId int64) ([]models.GuestbookComment, error) { + switch guestbookId { + default: + return []models.GuestbookComment{}, models.ErrNoRecord + } +} + +func (m *GuestbookCommentModel) UpdateComment(comment *models.GuestbookComment) error { + return nil +} diff --git a/internal/models/mocks/users.go b/internal/models/mocks/users.go new file mode 100644 index 0000000..2e5ff45 --- /dev/null +++ b/internal/models/mocks/users.go @@ -0,0 +1,89 @@ +package mocks + +import ( + "time" + + "git.32bit.cafe/32bitcafe/guestbook/internal/models" +) + +var mockUser = models.User{ + ID: 1, + ShortId: 1, + Username: "tester", + Email: "test@example.com", + Deleted: false, + IsBanned: false, + Created: time.Now(), + Settings: mockUserSettings, +} + +var mockUserSettings = models.UserSettings{ + LocalTimezone: time.UTC, +} + +type UserModel struct { + Settings map[string]models.Setting +} + +func (m *UserModel) InitializeSettingsMap() error { + return nil +} + +func (m *UserModel) Insert(shortId uint64, username string, email string, password string, settings models.UserSettings) error { + switch email { + case "dupe@example.com": + return models.ErrDuplicateEmail + default: + return nil + } +} + +func (m *UserModel) Get(shortId uint64) (models.User, error) { + switch shortId { + case 1: + return mockUser, nil + default: + return models.User{}, models.ErrNoRecord + } +} + +func (m *UserModel) GetById(id int64) (models.User, error) { + switch id { + case 1: + return mockUser, nil + default: + return models.User{}, models.ErrNoRecord + } +} + +func (m *UserModel) GetAll() ([]models.User, error) { + return []models.User{mockUser}, nil +} + +func (m *UserModel) Authenticate(email, password string) (int64, error) { + if email == "test@example.com" && password == "password" { + return 1, nil + } + return 0, models.ErrInvalidCredentials +} + +func (m *UserModel) Exists(id int64) (bool, error) { + switch id { + case 1: + return true, nil + default: + return false, nil + } +} + +func (m *UserModel) GetSettings(userId int64) (models.UserSettings, error) { + return mockUserSettings, nil +} + +func (m *UserModel) UpdateUserSettings(userId int64, settings models.UserSettings) error { + return nil +} + +func (m *UserModel) UpdateSetting(userId int64, setting models.Setting, value string) error { + return nil +} diff --git a/internal/models/mocks/website.go b/internal/models/mocks/website.go new file mode 100644 index 0000000..77791ff --- /dev/null +++ b/internal/models/mocks/website.go @@ -0,0 +1,50 @@ +package mocks + +import ( + "time" + + "git.32bit.cafe/32bitcafe/guestbook/internal/models" +) + +var mockWebsite = models.Website{ + ID: 1, + ShortId: 1, + Name: "Example", + SiteUrl: "example.com", + AuthorName: "John Test", + UserId: 1, + Created: time.Now(), + Guestbook: mockGuestbook, +} + +type WebsiteModel struct{} + +func (m *WebsiteModel) Insert(shortId uint64, userId int64, siteName, siteUrl, authorName string) (int64, error) { + return 2, nil +} + +func (m *WebsiteModel) Get(shortId uint64) (models.Website, error) { + switch shortId { + case 1: + return mockWebsite, nil + default: + return models.Website{}, models.ErrNoRecord + } +} + +func (m *WebsiteModel) GetAllUser(userId int64) ([]models.Website, error) { + return []models.Website{mockWebsite}, nil +} + +func (m *WebsiteModel) GetById(id int64) (models.Website, error) { + switch id { + case 1: + return mockWebsite, nil + default: + return models.Website{}, models.ErrNoRecord + } +} + +func (m *WebsiteModel) GetAll() ([]models.Website, error) { + return []models.Website{mockWebsite}, nil +} diff --git a/internal/models/user.go b/internal/models/user.go index acd9178..a3fa930 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -35,6 +35,19 @@ type UserModel struct { Settings map[string]Setting } +type UserModelInterface interface { + InitializeSettingsMap() error + Insert(shortId uint64, username string, email string, password string, settings UserSettings) error + Get(shortId uint64) (User, error) + GetById(id int64) (User, error) + GetAll() ([]User, error) + Authenticate(email, password string) (int64, error) + Exists(id int64) (bool, error) + GetSettings(userId int64) (UserSettings, error) + UpdateUserSettings(userId int64, settings UserSettings) error + UpdateSetting(userId int64, setting Setting, value string) error +} + func (m *UserModel) InitializeSettingsMap() error { if m.Settings == nil { m.Settings = make(map[string]Setting) diff --git a/internal/models/website.go b/internal/models/website.go index 456c92d..7b0e906 100644 --- a/internal/models/website.go +++ b/internal/models/website.go @@ -90,6 +90,14 @@ func (m *WebsiteModel) InitializeSettingsMap() error { return nil } +type WebsiteModelInterface interface { + Insert(shortId uint64, userId int64, siteName, siteUrl, authorName string) (int64, error) + Get(shortId uint64) (Website, error) + GetById(id int64) (Website, error) + GetAllUser(userId int64) ([]Website, error) + GetAll() ([]Website, error) +} + func (m *WebsiteModel) Insert(shortId uint64, userId int64, siteName, siteUrl, authorName string) (int64, error) { stmt := `INSERT INTO websites (ShortId, Name, SiteUrl, AuthorName, UserId, Created) VALUES (?, ?, ?, ?, ?, ?)`