BUGFIX: fix a bunch of issues with HTMX error-response toasts

- Add tests for a bunch of error cases and non-happy paths
This commit is contained in:
Alessio 2024-12-02 18:50:54 -08:00
parent 222f3d7ab5
commit 2edead5913
10 changed files with 203 additions and 30 deletions

View File

@ -15,7 +15,7 @@ func (app *Application) Bookmarks(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Has("scrape") {
if app.IsScrapingDisabled {
app.InfoLog.Printf("Would have scraped: %s", r.URL.Path)
http.Error(w, "Scraping is disabled (are you logged in?)", 401) // TODO: toast
app.error_401(w, r)
return
}

View File

@ -23,12 +23,47 @@ func TestBookmarksTab(t *testing.T) {
tweets := cascadia.QueryAll(root, selector(".timeline > .tweet"))
assert.Len(tweets, 2)
// Double check pagination works properly
resp = do_request_with_active_user(httptest.NewRequest("GET", "/bookmarks?cursor=1800452344077464795", nil))
// With pagination
req := httptest.NewRequest("GET", "/bookmarks?cursor=1800452344077464795", nil)
req.Header.Set("HX-Request", "true")
resp = do_request_with_active_user(req)
require.Equal(resp.StatusCode, 200)
root, err = html.Parse(resp.Body)
require.NoError(err)
tweets = cascadia.QueryAll(root, selector(".timeline > .tweet"))
tweets = cascadia.QueryAll(root, selector(".tweet"))
assert.Len(tweets, 1)
}
// When scraping is disabled, should 401
func TestBookmarksScrape(t *testing.T) {
require := require.New(t)
// Attempt to scrape with scraping disabled
resp := do_request_with_active_user(httptest.NewRequest("GET", "/bookmarks?scrape", nil))
require.Equal(resp.StatusCode, 401)
}
// If cursor is invalid, it should 400
func TestBookmarksInvalidCursor(t *testing.T) {
require := require.New(t)
assert := assert.New(t)
// HTMX version
req := httptest.NewRequest("GET", "/bookmarks?cursor=asdf", nil)
req.Header.Set("HX-Request", "true")
resp := do_request_with_active_user(req)
require.Equal(resp.StatusCode, 400)
// Piggyback in testing of HTMX 400 error toasts
assert.Equal("beforeend", resp.Header.Get("HX-Reswap"))
assert.Equal("#toasts", resp.Header.Get("HX-Retarget"))
assert.Equal("false", resp.Header.Get("HX-Push-Url"))
// Non-HTMX version
req1 := httptest.NewRequest("GET", "/bookmarks?cursor=asdf", nil)
resp1 := do_request_with_active_user(req1)
require.Equal(resp1.StatusCode, 400)
assert.Equal("", resp1.Header.Get("HX-Reswap"))
assert.Equal("", resp1.Header.Get("HX-Retarget"))
assert.Equal("", resp1.Header.Get("HX-Push-Url"))
}

View File

@ -24,23 +24,46 @@ func TestListsIndex(t *testing.T) {
assert.True(t, len(cascadia.QueryAll(root, selector(".list-preview"))) >= 2)
}
func TestListDetail(t *testing.T) {
// Show the users who are on a List
func TestListDetailUsers(t *testing.T) {
require := require.New(t)
assert := assert.New(t)
// Users
resp := do_request(httptest.NewRequest("GET", "/lists/1/users", nil))
require.Equal(resp.StatusCode, 200)
root, err := html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root, selector(".users-list .author-info")), 5)
}
// Feed
resp1 := do_request(httptest.NewRequest("GET", "/lists/2", nil))
require.Equal(resp1.StatusCode, 200)
root1, err := html.Parse(resp1.Body)
// Show the timeline geenrated for a List
func TestListFeed(t *testing.T) {
require := require.New(t)
assert := assert.New(t)
resp := do_request(httptest.NewRequest("GET", "/lists/2", nil))
require.Equal(resp.StatusCode, 200)
root, err := html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root1, selector(".timeline > .tweet")), 3)
assert.Len(cascadia.QueryAll(root, selector(".timeline > .tweet")), 3)
// With pagination
req1 := httptest.NewRequest("GET", "/lists/2?cursor=1629523652000", nil)
req1.Header.Set("HX-Request", "true")
resp1 := do_request(req1)
require.Equal(resp1.StatusCode, 200)
root2, err := html.Parse(resp1.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root2, selector(":not(.tweet__quoted-tweet) > .tweet")), 2)
}
func TestListFeedInvalidCursor(t *testing.T) {
require := require.New(t)
req := httptest.NewRequest("GET", "/lists/2?cursor=asdf", nil)
req.Header.Set("HX-Request", "true")
resp := do_request(req)
require.Equal(resp.StatusCode, 400)
}
func TestListDetailDoesntExist(t *testing.T) {
@ -89,7 +112,23 @@ func TestListAddAndDeleteUser(t *testing.T) {
assert.Len(cascadia.QueryAll(root, selector(".users-list .author-info")), 2)
}
func TestCreateNewList(t *testing.T) {
// Adding invalid users should 400
func TestListAddInvalidUser(t *testing.T) {
require := require.New(t)
resp := do_request(httptest.NewRequest("GET", "/lists/2/add_user?user_handle=jkwfjekj", nil))
require.Equal(resp.StatusCode, 400)
}
// Deleting invalid users should 400
func TestListRemoveInvalidUser(t *testing.T) {
require := require.New(t)
resp := do_request(httptest.NewRequest("GET", "/lists/2/remove_user?user_handle=fwefjkl", nil))
require.Equal(resp.StatusCode, 400)
}
func TestCreateNewListThenDelete(t *testing.T) {
require := require.New(t)
assert := assert.New(t)
@ -111,4 +150,16 @@ func TestCreateNewList(t *testing.T) {
root, err = html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root, selector(".list-preview")), num_lists+1)
// Delete it; should redirect back to Lists index page
resp_delete := do_request(httptest.NewRequest("DELETE", fmt.Sprintf("/lists/%d", num_lists+1), nil))
require.Equal(resp_delete.StatusCode, 302)
require.Equal("/lists", resp_delete.Header.Get("Location"))
// Should be N lists again
resp = do_request(httptest.NewRequest("GET", "/lists", nil))
require.Equal(resp.StatusCode, 200)
root, err = html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root, selector(".list-preview")), num_lists)
}

View File

@ -33,6 +33,11 @@ func (app *Application) message_mark_as_read(w http.ResponseWriter, r *http.Requ
c.PageSize = 1
chat_contents := app.Profile.GetChatRoomMessagesByCursor(c)
last_message_id := chat_contents.MessageIDs[len(chat_contents.MessageIDs)-1]
if app.IsScrapingDisabled {
app.InfoLog.Printf("Would have scraped: %s", r.URL.Path)
app.error_401(w, r)
return
}
panic_if(app.API.MarkDMChatRead(room_id, last_message_id))
room := chat_contents.Rooms[room_id]
participant, is_ok := room.Participants[app.ActiveUser.ID]
@ -42,7 +47,7 @@ func (app *Application) message_mark_as_read(w http.ResponseWriter, r *http.Requ
participant.LastReadEventID = last_message_id
room.Participants[app.ActiveUser.ID] = participant
panic_if(app.Profile.SaveChatRoom(room))
app.toast(w, r, Toast{
app.toast(w, r, 200, Toast{
Title: "Success",
Message: `Conversation marked as "read"`,
Type: "success",
@ -65,7 +70,11 @@ func (app *Application) message_send(w http.ResponseWriter, r *http.Request) {
if err != nil {
in_reply_to_id = 0
}
if app.IsScrapingDisabled {
app.InfoLog.Printf("Would have scraped: %s", r.URL.Path)
app.error_401(w, r)
return
}
trove, err := app.API.SendDMMessage(room_id, message_data.Text, scraper.DMMessageID(in_reply_to_id))
if err != nil {
panic(err)
@ -81,6 +90,10 @@ func (app *Application) message_detail(w http.ResponseWriter, r *http.Request) {
is_sending := len(parts) == 1 && parts[0] == "send"
chat_view_data, global_data := app.get_message_global_data()
if _, is_ok := chat_view_data.Rooms[room_id]; !is_ok {
app.error_404(w, r)
return
}
if len(parts) == 1 && parts[0] == "mark-as-read" {
app.message_mark_as_read(w, r)

View File

@ -2,6 +2,7 @@ package webserver_test
import (
"testing"
"strings"
"net/http/httptest"
@ -9,8 +10,32 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/html"
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
"gitlab.com/offline-twitter/twitter_offline_engine/internal/webserver"
)
func TestMessagesIndexPageRequiresActiveUser(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
// HTMX version
req := httptest.NewRequest("GET", "/messages", nil)
req.Header.Set("HX-Request", "true")
resp := do_request(req) // No active user
require.Equal(401, resp.StatusCode)
// Piggyback in testing of HTMX 401 toasts
assert.Equal("beforeend", resp.Header.Get("HX-Reswap"))
assert.Equal("#toasts", resp.Header.Get("HX-Retarget"))
assert.Equal("false", resp.Header.Get("HX-Push-Url"))
// Non-HTMX version
req1 := httptest.NewRequest("GET", "/messages", nil)
resp1 := do_request(req1) // No active user
require.Equal(401, resp1.StatusCode)
assert.Equal("", resp1.Header.Get("HX-Reswap")) // HX-* stuff should be unset
}
// Loading the index page should work if you're logged in
func TestMessagesIndexPage(t *testing.T) {
assert := assert.New(t)
@ -18,12 +43,32 @@ func TestMessagesIndexPage(t *testing.T) {
// Chat list
resp := do_request_with_active_user(httptest.NewRequest("GET", "/messages", nil))
require.Equal(200, resp.StatusCode)
root, err := html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root, selector(".chat-list .chat-list-entry")), 2)
assert.Len(cascadia.QueryAll(root, selector(".chat-view .dm-message")), 0) // No messages until you click on one
}
// Users should only be able to open chats they're a member of
func TestMessagesRoomRequiresCorrectUser(t *testing.T) {
require := require.New(t)
// No active user
resp := do_request(httptest.NewRequest("GET", "/messages/1488963321701171204-1178839081222115328", nil))
require.Equal(401, resp.StatusCode)
// Wrong user (not in the chat)
// Copied from `do_request_with_active_user`
recorder := httptest.NewRecorder()
app := webserver.NewApp(profile)
app.IsScrapingDisabled = true
app.ActiveUser = scraper.User{ID: 782982734, Handle: "Not a real user"} // Simulate a login
app.WithMiddlewares().ServeHTTP(recorder, httptest.NewRequest("GET", "/messages/1488963321701171204-1178839081222115328", nil))
resp2 := recorder.Result()
require.Equal(404, resp2.StatusCode)
}
// Open a chat room
func TestMessagesRoom(t *testing.T) {
assert := assert.New(t)
@ -31,6 +76,7 @@ func TestMessagesRoom(t *testing.T) {
// Chat detail
resp := do_request_with_active_user(httptest.NewRequest("GET", "/messages/1488963321701171204-1178839081222115328", nil))
require.Equal(200, resp.StatusCode)
root, err := html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root, selector(".chat-list .chat-list-entry")), 2) // Chat list still renders
@ -59,6 +105,7 @@ func TestMessagesRoomPollForUpdates(t *testing.T) {
req := httptest.NewRequest("GET", "/messages/1488963321701171204-1178839081222115328?poll&latest_timestamp=1686025129141", nil)
req.Header.Set("HX-Request", "true")
resp := do_request_with_active_user(req)
require.Equal(200, resp.StatusCode)
root, err := html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root, selector(".dm-message")), 3)
@ -86,6 +133,7 @@ func TestMessagesRoomPollForUpdatesEmptyResult(t *testing.T) {
req := httptest.NewRequest("GET", "/messages/1488963321701171204-1178839081222115328?poll&latest_timestamp=1686025129144", nil)
req.Header.Set("HX-Request", "true")
resp := do_request_with_active_user(req)
require.Equal(200, resp.StatusCode)
root, err := html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root, selector(".dm-message")), 0)
@ -113,7 +161,24 @@ func TestMessagesPaginate(t *testing.T) {
req := httptest.NewRequest("GET", "/messages/1488963321701171204-1178839081222115328?cursor=1686025129142", nil)
req.Header.Set("HX-Request", "true")
resp := do_request_with_active_user(req)
require.Equal(200, resp.StatusCode)
root, err := html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root, selector(".dm-message")), 2)
}
// When scraping is disabled, marking as read should 401
func TestMessagesMarkAsRead(t *testing.T) {
require := require.New(t)
resp := do_request_with_active_user(httptest.NewRequest("GET", "/messages/1488963321701171204-1178839081222115328/mark-as-read", nil))
require.Equal(resp.StatusCode, 401)
}
// When scraping is disabled, sending a message should 401
func TestMessagesSend(t *testing.T) {
require := require.New(t)
resp := do_request_with_active_user(httptest.NewRequest("GET", "/messages/1488963321701171204-1178839081222115328/send", strings.NewReader(`{"text": "bleh"}`)))
require.Equal(401, resp.StatusCode)
}

View File

@ -40,7 +40,7 @@ func (app *Application) NotificationsMarkAsRead(w http.ResponseWriter, r *http.R
if err != nil {
panic(err)
}
app.toast(w, r, Toast{
app.toast(w, r, 200, Toast{
Title: "Success",
Message: `Notifications marked as "read"`,
Type: "success",

View File

@ -98,7 +98,7 @@ func (app *Application) Search(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Has("scrape") {
if app.IsScrapingDisabled {
app.InfoLog.Printf("Would have scraped: %s", r.URL.Path)
http.Error(w, "Scraping is disabled (are you logged in?)", 401)
app.error_401(w, r)
return
}

View File

@ -42,7 +42,7 @@ func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Has("scrape") {
if app.IsScrapingDisabled {
app.InfoLog.Printf("Would have scraped: %s", r.URL.Path)
http.Error(w, "Scraping is disabled (are you logged in?)", 401)
app.error_401(w, r)
return
}
@ -155,7 +155,7 @@ func (app *Application) UserFollowees(w http.ResponseWriter, r *http.Request, us
if r.URL.Query().Has("scrape") {
if app.IsScrapingDisabled {
app.InfoLog.Printf("Would have scraped: %s", r.URL.Path)
http.Error(w, "Scraping is disabled (are you logged in?)", 401)
app.error_401(w, r)
return
}
@ -181,7 +181,7 @@ func (app *Application) UserFollowers(w http.ResponseWriter, r *http.Request, us
if r.URL.Query().Has("scrape") {
if app.IsScrapingDisabled {
app.InfoLog.Printf("Would have scraped: %s", r.URL.Path)
http.Error(w, "Scraping is disabled (are you logged in?)", 401)
app.error_401(w, r)
return
}

View File

@ -14,26 +14,27 @@ func panic_if(err error) {
func (app *Application) error_400_with_message(w http.ResponseWriter, r *http.Request, msg string) {
if is_htmx(r) {
w.WriteHeader(400)
app.toast(w, r, Toast{Title: "Bad Request", Message: msg, Type: "error"})
app.toast(w, r, 400, Toast{Title: "Bad Request", Message: msg, Type: "error"})
} else {
http.Error(w, fmt.Sprintf("Bad Request\n\n%s", msg), 400)
}
}
func (app *Application) error_401(w http.ResponseWriter, r *http.Request) {
msg := "Please log in or set an active session."
if app.ActiveUser.ID != 0 {
msg += " (There is currently an active user, but scraping is disabled.)"
}
if is_htmx(r) {
w.WriteHeader(401)
app.toast(w, r, Toast{Title: "Login required", Message: "Please log in or set an active session", Type: "error"})
app.toast(w, r, 401, Toast{Title: "Login required", Message: msg, Type: "error"})
} else {
http.Error(w, "Please log in or set an active session", 401)
http.Error(w, msg, 401)
}
}
func (app *Application) error_404(w http.ResponseWriter, r *http.Request) {
if is_htmx(r) {
w.WriteHeader(404)
app.toast(w, r, Toast{Title: "Not found", Type: "error"})
app.toast(w, r, 404, Toast{Title: "Not found", Type: "error"})
} else {
http.Error(w, "Not Found", 404)
}
@ -45,15 +46,23 @@ func (app *Application) error_500(w http.ResponseWriter, r *http.Request, err er
if err2 != nil {
panic(err2)
}
app.toast(w, r, Toast{Title: "Server error", Message: err.Error(), Type: "error"})
if is_htmx(r) {
app.toast(w, r, 500, Toast{Title: "Server error", Message: err.Error(), Type: "error"})
} else {
http.Error(w, err.Error(), 500)
}
}
func (app *Application) toast(w http.ResponseWriter, r *http.Request, t Toast) {
// The toast is the primary payload (i.e., not OOB)
func (app *Application) toast(w http.ResponseWriter, r *http.Request, status_code int, t Toast) {
// Reset the HTMX response to return an error toast and append it to the Toasts container
w.Header().Set("HX-Reswap", "beforeend")
w.Header().Set("HX-Retarget", "#toasts")
w.Header().Set("HX-Push-Url", "false")
w.WriteHeader(status_code) // Must be called after all `Header.Set(...)`
app.buffered_render_htmx(w, "toast", PageGlobalData{}, t)
}

View File

@ -46,7 +46,7 @@ func do_request(req *http.Request) *http.Response {
recorder := httptest.NewRecorder()
app := webserver.NewApp(profile)
app.IsScrapingDisabled = true
app.ServeHTTP(recorder, req)
app.WithMiddlewares().ServeHTTP(recorder, req)
return recorder.Result()
}
@ -56,7 +56,7 @@ func do_request_with_active_user(req *http.Request) *http.Response {
app := webserver.NewApp(profile)
app.IsScrapingDisabled = true
app.ActiveUser = scraper.User{ID: 1488963321701171204, Handle: "Offline_Twatter"} // Simulate a login
app.ServeHTTP(recorder, req)
app.WithMiddlewares().ServeHTTP(recorder, req)
return recorder.Result()
}