package web import ( "html/template" "net/http/httptest" "net/url" "strings" "testing" ) // 数据编辑页的翻页链接必须「带条件、不双重转义」。这两件事都踩过坑: // // - 等号被 URL 上下文转义成 %3d:`mt%3d4` 在服务端解出来是「键为 mt=4」的参数, // 筛选条件翻页时静默失效 —— 所以 PageLink 返回 template.URL; // - & 被手工写成 &:template.URL 在 href 里仍会过一次 urlNormalizer,它自己会做 // HTML 属性转义,手工写一遍的结果是 `&amp;`,浏览器解出来是「字面的 &」, // 参数名变成 `amp;vpage`,表现为「带筛选时翻页没反应、筛选还丢了」(实测确认过)。 func TestEditPageLink(t *testing.T) { d := EditData{Page: Page{Back: template.URL("/places?city=河池市")}} d.Query = "家" d.VisitQuery = "咖啡" got := string(d.PageLink("vpage", 2)) if strings.Contains(got, "&") { t.Errorf("含 & 会被二次转义成 &amp;:%s", got) } for _, want := range []string{"q=%E5%AE%B6", "vq=", "back=%2Fplaces", "vpage=2"} { if !strings.Contains(got, want) { t.Errorf("翻页链接丢了 %q:%s", want, got) } } // 键值之间的等号必须是裸的:写成 %3d 时服务端解出来是「键为 vpage=2」的参数, // 翻页静默失效。上面逐项断言 "vpage=2" 就是在守这条。 // (back 的值里出现 %3D 是正常的 —— 那是内层地址自己的编码,必须保持。) if !strings.HasPrefix(got, "/admin/edit?") { t.Errorf("链接开头不对:%s", got) } // 分隔符必须是裸的 &(交给模板那层做属性转义) if strings.Count(got, "&") != 3 { t.Errorf("参数分隔符数量不对(期望 3 个裸 &):%s", got) } // 没有 back 时不该凭空多出参数 bare := string(EditData{}.PageLink("page", 3)) if bare != "/admin/edit?page=3" { t.Errorf("无筛选时链接不对:%s", bare) } } // 「在地图中查看」把地点名当查询参数拼进地址,名字里的 & / # / 空格都必须编码: // 直接内插的话 & 会把参数拆断、# 会把后面整段当 fragment 吃掉。 func TestMapLink(t *testing.T) { got := string(Page{}.MapLink("A&B #咖啡 100%")) want := "/map?q=A%26B+%23%E5%92%96%E5%95%A1+100%25" if got != want { t.Errorf("MapLink = %q,期望 %q", got, want) } if got := string(Page{}.MapLink(" 家 ")); got != "/map?q=%E5%AE%B6" { t.Errorf("首尾空白应去掉:%q", got) } } // EditPlaceLink 是「在数据编辑页打开这个地点」,它把返回地址挂在 ?back= 上。 // // 这里只允许编码**一次**。退回当前页时以前用的是 BackQS(已经 QueryEscape 过), // 再转一次就成了 %252F…,服务端解一次拿到 %2F… 不以 / 开头,被 backFromRequest // 当站外地址丢掉 —— 直接打开 /places/1 再点「编辑」,编辑页的返回按钮就凭空消失。 func TestEditPlaceLinkBackIsEncodedOnce(t *testing.T) { const self = "/places/1?city=河池市&sort=dwell" cases := []struct { name string p Page want string // back 参数解码一次后的原值 }{ {"列表页给的 back", Page{ Back: template.URL("/places?city=河池市"), SelfPath: self, BackQS: template.URL(url.QueryEscape(self)), }, "/places?city=河池市"}, {"back 为空时退回当前页", Page{ SelfPath: self, BackQS: template.URL(url.QueryEscape(self)), }, self}, } for _, c := range cases { got := string(c.p.EditPlaceLink(2141)) i := strings.Index(got, "&back=") if i < 0 { t.Fatalf("%s:链接里没有 back 参数:%s", c.name, got) } encoded := got[i+len("&back="):] raw, err := url.QueryUnescape(encoded) if err != nil { t.Fatalf("%s:back 值解不出来:%v", c.name, err) } if raw != c.want { t.Errorf("%s:back 解码一次 = %q,期望 %q(链接 %s)", c.name, raw, c.want, got) } // 服务端真的会收这一份:解出来的必须还能通过站内校验,否则返回按钮是空的 r := httptest.NewRequest("GET", "/admin/edit?open=2141&back="+encoded, nil) if bk := backFromRequest(r); string(bk) != c.want { t.Errorf("%s:服务端解出 back=%q,期望 %q —— 返回按钮会消失", c.name, bk, c.want) } } } // 返回按钮的文案要从 back 里认出来源。踩过的坑:先取「第一个路径段」但没剥查询串, // 于是 `/places?city=X` 这种「带筛选、没有下级路径」的整串都是第一段,谁都匹配不上, // 按钮退化成光秃秃的「返回」。 func TestBackLabel(t *testing.T) { cases := []struct{ back, want string }{ {"/places?city=河池市&sort=dwell", "返回地点库"}, {"/places/2141", "返回地点库"}, {"/timeline?from=2026-01-01", "返回时间线"}, {"/map?q=家", "返回地图"}, {"/stats", "返回数据统计"}, {"/topics/3", "返回专题"}, {"/admin/edit?q=家", "返回数据编辑"}, {"/admin", "返回后台"}, {"/", "返回首页"}, {"/something-else", "返回"}, {"", ""}, } for _, c := range cases { p := Page{} if c.back != "" { p.Back = template.URL(c.back) } if got := p.BackLabel(); got != c.want { t.Errorf("BackLabel(%q) = %q,期望 %q", c.back, got, c.want) } } }