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) {
77 t.Errorf("next link drops comments=1:\n%s", out) 77 t.Errorf("next link drops comments=1:\n%s", out)
78 } 78 }
79} 79}
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 (
18var ( 18var (
19 fetchDeviation = devianter.GetDeviation 19 fetchDeviation = devianter.GetDeviation
20 fetchComments = devianter.GetComments 20 fetchComments = devianter.GetComments
21 fetchProfile = func(name string) (devianter.GRuser, devianter.Error, error) {
22 g := devianter.Group{Name: name}
23 return g.Get()
24 }
21) 25)
22 26
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
23// commentsOrLink renders a comment thread only when the request asked for it 40// commentsOrLink renders a comment thread only when the request asked for it
24// with ?comments=1, and otherwise a link that does. A thread is a second 41// with ?comments=1, and otherwise a link that does. A thread is a second
25// upstream call on every post and profile view, and most viewers never open 42// upstream call on every post and profile view, and most viewers never open
@@ -60,7 +77,7 @@ func (s skunkyart) GRUser() {
60 var daError devianter.Error 77 var daError devianter.Error
61 g.Name = s.Query 78 g.Name = s.Query
62 var err error 79 var err error
63 s.Templates.GroupUser.GR, daError, err = g.Get() 80 s.Templates.GroupUser.GR, daError, err = fetchProfile(s.Query)
64 try(err) 81 try(err)
65 if daError.RAW != nil { 82 if daError.RAW != nil {
66 s.Error(daError) 83 s.Error(daError)
@@ -81,7 +98,7 @@ func (s skunkyart) GRUser() {
81 group.Group = true 98 group.Group = true
82 group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String() 99 group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String()
83 group.About.DescriptionFormatted = template.HTML(ParseDescription(s.Host, about.Description)) //nolint:gosec // G203: ParseDescription escapes its input 100 group.About.DescriptionFormatted = template.HTML(ParseDescription(s.Host, about.Description)) //nolint:gosec // G203: ParseDescription escapes its input
84 } else if false { 101 } else {
85 group.About.A = x.ModuleData.About 102 group.About.A = x.ModuleData.About
86 var about = &group.About.A 103 var about = &group.About.A
87 group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String() 104 group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String()
@@ -318,20 +335,10 @@ func (s skunkyart) Search() {
318 case 'r': // scraper, since DeviantArt withholds the guest API for group search 335 case 'r': // scraper, since DeviantArt withholds the guest API for group search
319 var ( 336 var (
320 usernames = make(map[int]string) 337 usernames = make(map[int]string)
321 url strings.Builder
322 num int 338 num int
323 ) 339 )
324 340
325 s.Page++ 341 dwnld := Download(groupSearchURL(s.Query, 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())
335 342
336 for z := html.NewTokenizer(strings.NewReader(string(dwnld.Body))); ; { 343 for z := html.NewTokenizer(strings.NewReader(string(dwnld.Body))); ; {
337 if n, token := z.Next(), z.Token(); n == html.StartTagToken && token.Data == "a" { 344 if n, token := z.Next(), z.Token(); n == html.StartTagToken && token.Data == "a" {
app/wrapper_test.go +48
@@ -1,9 +1,14 @@
1package app 1package app
2 2
3import ( 3import (
4 "encoding/json"
4 "errors" 5 "errors"
5 "net/http/httptest" 6 "net/http/httptest"
7 "net/url"
8 "strings"
6 "testing" 9 "testing"
10
11 "github.com/krazywarez/devianter"
7) 12)
8 13
9// withAvatarCache turns the media cache on over a temporary directory and 14// withAvatarCache turns the media cache on over a temporary directory and
@@ -67,3 +72,46 @@ func TestEmojitarFetchesEveryTimeWithCacheOff(t *testing.T) {
67 t.Errorf("avatar fetched %d times, want 2 with the cache off", *calls) 72 t.Errorf("avatar fetched %d times, want 2 with the cache off", *calls)
68 } 73 }
69} 74}
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}