feat: add edit/delete buttons to list views and inline privacy toggle

Fave cards in the list and profile views now show edit, delete, and
privacy toggle buttons directly — no need to open the detail page first.

- New POST /faves/{id}/privacy route with HTMX privacy toggle partial
- New UpdatePrivacy store method for single-column update
- fave_list.html: edit link, HTMX delete, privacy toggle on every card
- profile.html: edit/delete for owner's own cards
- privacy_toggle.html: new HTMX partial that swaps inline on toggle
- CSS: compact .fave-card-actions styles

The existing handleFaveDelete already returns empty 200 for HTMX
requests, so hx-target="closest article" hx-swap="outerHTML" removes
the card from DOM seamlessly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-07 10:17:46 +02:00
commit b186fb4bc5
9 changed files with 360 additions and 3 deletions

View file

@ -1282,6 +1282,197 @@ func TestFaveNewPreFill(t *testing.T) {
}
}
// --- Privacy toggle ---
func TestTogglePrivacyOwner(t *testing.T) {
h, mux := testServer(t)
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Toggle me", "", "", "", "public")
token, _ := h.deps.Sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
getReq := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil)
getReq.AddCookie(cookie)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
req := postForm("/faves/"+faveIDStr(fave.ID)+"/privacy", csrf, url.Values{}, cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("toggle privacy: got %d, want 200\nbody: %s", rr.Code, rr.Body.String())
}
// Should now be private.
updated, _ := h.deps.Faves.GetByID(fave.ID)
if updated.Privacy != "private" {
t.Errorf("privacy = %q, want private after toggle from public", updated.Privacy)
}
// Response should contain the toggle partial with "Privat".
if !strings.Contains(rr.Body.String(), "Privat") {
t.Error("toggle response should show new privacy state")
}
}
func TestTogglePrivacyNotOwner(t *testing.T) {
h, mux := testServer(t)
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public")
cookieB := loginUser(t, h, "userb", "pass123", "user")
getReq := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil)
getReq.AddCookie(cookieB)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
req := postForm("/faves/"+faveIDStr(fave.ID)+"/privacy", csrf, url.Values{}, cookieB)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("toggle by non-owner: got %d, want 403", rr.Code)
}
}
func TestFaveListShowsEditButton(t *testing.T) {
h, mux := testServer(t)
cookie := loginUser(t, h, "testuser", "pass123", "user")
user, _ := h.deps.Users.GetByUsername("testuser")
h.deps.Faves.Create(user.ID, "Editable fave", "", "", "", "public")
req := httptest.NewRequest("GET", "/faves", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, "Rediger") {
t.Error("fave list should show edit link")
}
if !strings.Contains(body, "Slett") {
t.Error("fave list should show delete button")
}
if !strings.Contains(body, "Offentlig") {
t.Error("fave list should show privacy toggle")
}
}
// --- Admin: role + delete ---
func TestAdminSetRoleSuccess(t *testing.T) {
h, mux := testServer(t)
cookie := loginUser(t, h, "admin", "pass123", "admin")
user, _ := h.deps.Users.Create("target", "pass123", "user")
getReq := httptest.NewRequest("GET", "/admin/users", nil)
getReq.AddCookie(cookie)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
form := url.Values{"role": {"admin"}}
req := postForm("/admin/users/"+faveIDStr(user.ID)+"/role", csrf, form, cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "endret til admin") {
t.Errorf("should show role changed: %s", rr.Body.String())
}
updated, _ := h.deps.Users.GetByID(user.ID)
if updated.Role != "admin" {
t.Errorf("role = %q, want admin", updated.Role)
}
}
func TestAdminSetRoleSelf(t *testing.T) {
h, mux := testServer(t)
admin, _ := h.deps.Users.Create("admin", "pass123", "admin")
token, _ := h.deps.Sessions.Create(admin.ID)
cookie := &http.Cookie{Name: "session", Value: token}
getReq := httptest.NewRequest("GET", "/admin/users", nil)
getReq.AddCookie(cookie)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
form := url.Values{"role": {"user"}}
req := postForm("/admin/users/"+faveIDStr(admin.ID)+"/role", csrf, form, cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "din egen rolle") {
t.Error("should prevent changing own role")
}
}
func TestAdminDeleteUserSuccess(t *testing.T) {
h, mux := testServer(t)
cookie := loginUser(t, h, "admin", "pass123", "admin")
user, _ := h.deps.Users.Create("deleteme", "pass123", "user")
h.deps.Faves.Create(user.ID, "Will be deleted", "", "", "", "public")
getReq := httptest.NewRequest("GET", "/admin/users", nil)
getReq.AddCookie(cookie)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
req := postForm("/admin/users/"+faveIDStr(user.ID)+"/delete", csrf, url.Values{}, cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "permanent slettet") {
t.Errorf("should show deleted: %s", rr.Body.String())
}
// User should be gone.
_, err := h.deps.Users.GetByUsername("deleteme")
if err == nil {
t.Error("deleted user should not exist")
}
// Faves should be cascade-deleted.
faves, total, _ := h.deps.Faves.ListByUser(user.ID, 10, 0)
if total != 0 || len(faves) != 0 {
t.Error("faves should be cascade-deleted with user")
}
}
func TestAdminDeleteUserSelf(t *testing.T) {
h, mux := testServer(t)
admin, _ := h.deps.Users.Create("admin", "pass123", "admin")
token, _ := h.deps.Sessions.Create(admin.ID)
cookie := &http.Cookie{Name: "session", Value: token}
getReq := httptest.NewRequest("GET", "/admin/users", nil)
getReq.AddCookie(cookie)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
req := postForm("/admin/users/"+faveIDStr(admin.ID)+"/delete", csrf, url.Values{}, cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "din egen konto") {
t.Error("should prevent self-deletion")
}
}
// --- Export page ---
func TestExportPageRendering(t *testing.T) {