Commit 1ea3f7a94a

1ea3f7a94ac46319acd3836b295c9161a744cf9c

parent: 40fb6f041d

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-11 02:59 UTC

Restore the user About tab and fix group search paging

The person branch of the about module was disabled upstream with
`else if false`, so interests, social links and registration age never
rendered for users. Group search asked for offset 10*page after
incrementing the page, skipping results 10 to 19; the URL is now built
by groupSearchURL from the request's page without mutating it.

Closes #14
Closes #15

Layout: unified · split

app/comments_test.go +14
@@ -77,3 +77,17 @@ func TestNavBaseKeepsTheCommentsParameter(t *testing.T) {
7777 t.Errorf("next link drops comments=1:\n%s", out)
7878 }
7979}
80
81func TestGroupSearchURLPagesByTen(t *testing.T) {
82 cases := map[int]string{
83 0: "https://www.deviantart.com/groups/?q=cats",
84 1: "https://www.deviantart.com/groups/?q=cats",
85 2: "https://www.deviantart.com/groups/?q=cats&offset=10",
86 3: "https://www.deviantart.com/groups/?q=cats&offset=20",
87 }
88 for page, want := range cases {
89 if got := groupSearchURL("cats", page); got != want {
90 t.Errorf("page %d: %s, want %s", page, got, want)
91 }
92 }
93}
app/wrapper.go +20 −13
@@ -18,8 +18,25 @@ import (
1818var (
1919 fetchDeviation = devianter.GetDeviation
2020 fetchComments = devianter.GetComments
21 fetchProfile = func(name string) (devianter.GRuser, devianter.Error, error) {
22 g := devianter.Group{Name: name}
23 return g.Get()
24 }
2125)
2226
27// groupSearchURL is the DeviantArt group search page for query, paged ten
28// results at a time. page counts from 1; 0 means the first page too.
29func groupSearchURL(query string, page int) string {
30 var url strings.Builder
31 url.WriteString("https://www.deviantart.com/groups/?q=")
32 url.WriteString(query)
33 if page > 1 {
34 url.WriteString("&offset=")
35 url.WriteString(strconv.Itoa(10 * (page - 1)))
36 }
37 return url.String()
38}
39
2340// commentsOrLink renders a comment thread only when the request asked for it
2441// with ?comments=1, and otherwise a link that does. A thread is a second
2542// upstream call on every post and profile view, and most viewers never open
@@ -60,7 +77,7 @@ func (s skunkyart) GRUser() {
6077 var daError devianter.Error
6178 g.Name = s.Query
6279 var err error
63 s.Templates.GroupUser.GR, daError, err = g.Get()
80 s.Templates.GroupUser.GR, daError, err = fetchProfile(s.Query)
6481 try(err)
6582 if daError.RAW != nil {
6683 s.Error(daError)
@@ -81,7 +98,7 @@ func (s skunkyart) GRUser() {
8198 group.Group = true
8299 group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String()
83100 group.About.DescriptionFormatted = template.HTML(ParseDescription(s.Host, about.Description)) //nolint:gosec // G203: ParseDescription escapes its input
84 } else if false {
101 } else {
85102 group.About.A = x.ModuleData.About
86103 var about = &group.About.A
87104 group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String()
@@ -318,20 +335,10 @@ func (s skunkyart) Search() {
318335 case 'r': // scraper, since DeviantArt withholds the guest API for group search
319336 var (
320337 usernames = make(map[int]string)
321 url strings.Builder
322338 num int
323339 )
324340
325 s.Page++
326
327 url.WriteString("https://www.deviantart.com/groups/?q=")
328 url.WriteString(s.Query)
329 if s.Page > 1 {
330 url.WriteString("&offset=")
331 url.WriteString(strconv.Itoa(10 * s.Page))
332 }
333
334 dwnld := Download(url.String())
341 dwnld := Download(groupSearchURL(s.Query, s.Page))
335342
336343 for z := html.NewTokenizer(strings.NewReader(string(dwnld.Body))); ; {
337344 if n, token := z.Next(), z.Token(); n == html.StartTagToken && token.Data == "a" {
app/wrapper_test.go +48
@@ -1,9 +1,14 @@
11package app
22
33import (
4 "encoding/json"
45 "errors"
56 "net/http/httptest"
7 "net/url"
8 "strings"
69 "testing"
10
11 "github.com/krazywarez/devianter"
712)
813
914// withAvatarCache turns the media cache on over a temporary directory and
@@ -67,3 +72,46 @@ func TestEmojitarFetchesEveryTimeWithCacheOff(t *testing.T) {
6772 t.Errorf("avatar fetched %d times, want 2 with the cache off", *calls)
6873 }
6974}
75
76// TestUserAboutRendersProfileDetails is the regression test for the branch
77// upstream had disabled with `else if false`: a person's about tab must show
78// their interests, social links and how long they have been registered. The
79// profile is decoded from JSON shaped like DeviantArt's, since the module
80// slice's element type embeds an unexported struct and cannot be built by
81// name from here.
82func TestUserAboutRendersProfileDetails(t *testing.T) {
83 const profile = `{
84 "owner": {"isGroup": false, "username": "alice"},
85 "gruser": {"gruserId": 42, "page": {"modules": [
86 {"name": "about", "moduleData": {"about": {
87 "deviantFor": 86400,
88 "interests": [{"label": "Favourite animal", "value": "skunk"}],
89 "socialLinks": [{"value": "https://social.example/alice"}]
90 }}}
91 ]}}
92 }`
93 orig := fetchProfile
94 fetchProfile = func(string) (devianter.GRuser, devianter.Error, error) {
95 var p devianter.GRuser
96 if err := json.Unmarshal([]byte(profile), &p); err != nil {
97 t.Fatal(err)
98 }
99 return p, devianter.Error{}, nil
100 }
101 t.Cleanup(func() { fetchProfile = orig })
102
103 loadTemplates()
104 rec := httptest.NewRecorder()
105 s := skunkyart{Writer: rec, Host: "http://localhost", BasePath: "/", Type: 'a', Query: "alice", Args: url.Values{}, _pth: "/group_user"}
106 s.GRUser()
107
108 body := rec.Body.String()
109 for _, want := range []string{"Favourite animal: <b>skunk</b>", `href="https://social.example/alice"`, "Registration date"} {
110 if !strings.Contains(body, want) {
111 t.Errorf("about page lacks %q:\n%s", want, body)
112 }
113 }
114 if strings.Contains(body, "0001-01-01") {
115 t.Error("registration date is the zero time")
116 }
117}