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