816 lines
31 KiB
Go
816 lines
31 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config"
|
|
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/service"
|
|
"github.com/sirupsen/logrus"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
const testAdminSessionSecret = "0123456789abcdef0123456789abcdef"
|
|
|
|
func TestAdminLoginProtectsPagesAndRendersPriceInquiries(t *testing.T) {
|
|
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
|
|
AdminUsername: "test-admin",
|
|
AdminPassword: "test-password",
|
|
AdminSessionSecret: testAdminSessionSecret,
|
|
})
|
|
defer closeDB()
|
|
|
|
resp := doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", nil)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusSeeOther)
|
|
}
|
|
if location := resp.Header.Get("Location"); !strings.HasPrefix(location, "/admin/login?next=") {
|
|
t.Fatalf("Location = %q, want login redirect", location)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodGet, "/admin/projects", "", map[string]string{"Accept": "application/json"})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
|
|
}
|
|
|
|
cookie := adminSessionCookieHeader(config.Config{
|
|
AdminSessionSecret: testAdminSessionSecret,
|
|
}, adminUser{Subject: "test-admin", DisplayName: "王医生"})
|
|
resp = doRequest(t, app, http.MethodGet, "/admin/", "", map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("admin root status = %d, want %d", resp.StatusCode, http.StatusSeeOther)
|
|
}
|
|
if location := resp.Header.Get("Location"); location != defaultAdminPage {
|
|
t.Fatalf("admin root Location = %q, want %q", location, defaultAdminPage)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
body := readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
if !strings.Contains(body, "价格咨询派单") || !strings.Contains(body, "创建时间") || !strings.Contains(body, "最近提交时间") || !strings.Contains(body, "备注") || !strings.Contains(body, "设置") {
|
|
t.Fatalf("price inquiries page did not render expected columns: %s", body)
|
|
}
|
|
for _, expected := range []string{
|
|
"共 0 条咨询,第 1 / 1 页",
|
|
`<select id="page_size" name="page_size">`,
|
|
`<option value="50" selected>50 条/页</option>`,
|
|
`<span class="button secondary small disabled">上一页</span>`,
|
|
`<span class="button secondary small disabled">下一页</span>`,
|
|
} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Fatalf("price inquiries page missing pagination element %q: %s", expected, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "<h1>价格咨询派单</h1>") || strings.Contains(body, "已授权手机号提交的地区和咨询项目") || strings.Contains(body, "咨询单 ID") {
|
|
t.Fatalf("price inquiries page rendered removed title or ID column: %s", body)
|
|
}
|
|
if !strings.Contains(body, "王医生") {
|
|
t.Fatalf("price inquiries page did not render admin name: %s", body)
|
|
}
|
|
if strings.Contains(body, "/admin/phones") || strings.Contains(body, "/admin/booking-projects") || strings.Contains(body, "/admin/project-config") {
|
|
t.Fatalf("price inquiries page should not render removed module links: %s", body)
|
|
}
|
|
if strings.Contains(body, "修改密码") || strings.Contains(body, "OIDC") {
|
|
t.Fatalf("price inquiries page rendered removed OIDC controls: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestAdminLoginPageRendersCredentialForm(t *testing.T) {
|
|
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
|
|
AdminUsername: "operator",
|
|
AdminPassword: "local-password",
|
|
AdminSessionSecret: testAdminSessionSecret,
|
|
})
|
|
defer closeDB()
|
|
|
|
resp := doRequest(t, app, http.MethodGet, "/admin/login?next=%2Fadmin%2Fprice-inquiries", "", map[string]string{
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
body := readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
for _, expected := range []string{`action="/admin/login"`, `name="username"`, `name="password"`, `name="next"`} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Fatalf("login page missing %q: %s", expected, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "OIDC") || strings.Contains(body, "/admin/oidc/") {
|
|
t.Fatalf("login page rendered removed OIDC controls: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestAdminLoginAcceptsCredentialsAndSetsSession(t *testing.T) {
|
|
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
|
|
AdminUsername: "operator",
|
|
AdminPassword: "local-password",
|
|
AdminSessionSecret: testAdminSessionSecret,
|
|
})
|
|
defer closeDB()
|
|
|
|
invalid := url.Values{"username": {"operator"}, "password": {"wrong"}, "next": {"/admin"}}
|
|
resp := doRequest(t, app, http.MethodPost, "/admin/login", invalid.Encode(), map[string]string{
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
})
|
|
body := readBody(t, resp)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(body, "用户名或密码错误") {
|
|
t.Fatalf("invalid login status/body = %d/%s, want 401 with generic error", resp.StatusCode, body)
|
|
}
|
|
if resp.Header.Get("Set-Cookie") != "" {
|
|
t.Fatalf("invalid login issued a cookie: %s", resp.Header.Get("Set-Cookie"))
|
|
}
|
|
|
|
valid := url.Values{"username": {"operator"}, "password": {"local-password"}, "next": {"/admin/price-inquiries"}}
|
|
resp = doRequest(t, app, http.MethodPost, "/admin/login", valid.Encode(), map[string]string{
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/admin/price-inquiries" {
|
|
t.Fatalf("valid login status/location = %d/%q, want redirect to admin page", resp.StatusCode, resp.Header.Get("Location"))
|
|
}
|
|
var session *http.Cookie
|
|
for _, cookie := range resp.Cookies() {
|
|
if cookie.Name == adminSessionCookie {
|
|
session = cookie
|
|
break
|
|
}
|
|
}
|
|
if session == nil || !session.HttpOnly || session.Path != "/admin" || session.SameSite != http.SameSiteLaxMode {
|
|
t.Fatalf("invalid admin session cookie: %#v", session)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", map[string]string{
|
|
"Cookie": session.Name + "=" + session.Value,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("authenticated page status = %d, want %d", resp.StatusCode, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
func TestAdminLoginRateLimit(t *testing.T) {
|
|
app, closeDB := newTestAdminApp(t)
|
|
defer closeDB()
|
|
|
|
body := url.Values{"username": {"wrong"}, "password": {"wrong"}}.Encode()
|
|
for attempt := 1; attempt <= 6; attempt++ {
|
|
resp := doRequest(t, app, http.MethodPost, "/admin/login", body, map[string]string{
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
})
|
|
resp.Body.Close()
|
|
want := http.StatusUnauthorized
|
|
if attempt == 6 {
|
|
want = http.StatusTooManyRequests
|
|
}
|
|
if resp.StatusCode != want {
|
|
t.Fatalf("attempt %d status = %d, want %d", attempt, resp.StatusCode, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAdminRemovedModuleRoutesReturnNotFound(t *testing.T) {
|
|
app, closeDB := newTestAdminApp(t)
|
|
defer closeDB()
|
|
|
|
cookie := loginAdmin(t, app)
|
|
for _, route := range []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{method: http.MethodGet, path: "/api/app/bootstrap"},
|
|
{method: http.MethodGet, path: "/api/projects"},
|
|
{method: http.MethodGet, path: "/api/bookings"},
|
|
{method: http.MethodPost, path: "/api/bookings", body: "{}"},
|
|
{method: http.MethodGet, path: "/api/bookings/old"},
|
|
{method: http.MethodPatch, path: "/api/bookings/old/cancel", body: "{}"},
|
|
{method: http.MethodGet, path: "/admin/oidc/start"},
|
|
{method: http.MethodGet, path: "/admin/oidc/callback"},
|
|
{method: http.MethodGet, path: "/admin/phones"},
|
|
{method: http.MethodGet, path: "/admin/categories"},
|
|
{method: http.MethodPost, path: "/admin/categories", body: "{}"},
|
|
{method: http.MethodGet, path: "/admin/projects"},
|
|
{method: http.MethodPost, path: "/admin/projects", body: "{}"},
|
|
{method: http.MethodGet, path: "/admin/schedules"},
|
|
{method: http.MethodPost, path: "/admin/schedules/days", body: "{}"},
|
|
{method: http.MethodPatch, path: "/admin/schedules/days/day", body: "{}"},
|
|
{method: http.MethodPost, path: "/admin/schedules/slots", body: "{}"},
|
|
{method: http.MethodPatch, path: "/admin/schedules/slots/slot", body: "{}"},
|
|
{method: http.MethodGet, path: "/admin/bookings"},
|
|
{method: http.MethodPatch, path: "/admin/bookings/booking/status", body: "{}"},
|
|
{method: http.MethodGet, path: "/admin/reports/bookings"},
|
|
{method: http.MethodGet, path: "/admin/booking-projects"},
|
|
{method: http.MethodGet, path: "/admin/project-config"},
|
|
{method: http.MethodPost, path: "/admin/project-config/projects", body: "id=project_fresh_clean"},
|
|
} {
|
|
resp := doRequest(t, app, route.method, route.path, route.body, map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
"Content-Type": "application/json",
|
|
})
|
|
body := readBody(t, resp)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("%s %s status = %d, want %d: %s", route.method, route.path, resp.StatusCode, http.StatusNotFound, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPriceInquirySavesRegionAndAppearsInAdminDispatchList(t *testing.T) {
|
|
app, closeDB := newTestAdminApp(t)
|
|
defer closeDB()
|
|
|
|
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", `{"code":"price_inquiry_test"}`, map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("session status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp))
|
|
}
|
|
var session struct {
|
|
User struct {
|
|
Openid string `json:"openid"`
|
|
} `json:"user"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
|
t.Fatalf("decode session: %v", err)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phone":"13900005555"}`, map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp))
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodPost, "/api/price-inquiries", `{"openid":"`+session.User.Openid+`","province":"上海市","city":"上海市","district":"浦东新区","project_name":"半月板损伤"}`, map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
defer resp.Body.Close()
|
|
body := readBody(t, resp)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusCreated, body)
|
|
}
|
|
var createdInquiry struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &createdInquiry); err != nil {
|
|
t.Fatalf("decode price inquiry: %v", err)
|
|
}
|
|
if createdInquiry.ID == "" || createdInquiry.Status != "pending" {
|
|
t.Fatalf("price inquiry = %+v, want id and pending status", createdInquiry)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodPost, "/api/price-inquiries", `{"openid":"`+session.User.Openid+`","province":"上海市","city":"上海市","district":"浦东新区","project_name":"半月板损伤"}`, map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
defer resp.Body.Close()
|
|
body = readBody(t, resp)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("duplicate price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusCreated, body)
|
|
}
|
|
if !strings.Contains(body, `"updated_at":`) {
|
|
t.Fatalf("duplicate price inquiry body = %s, want updated_at", body)
|
|
}
|
|
|
|
cookie := loginAdmin(t, app)
|
|
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?area=浦东&phone=13900005555&time_from=2000-01-01&time_to=2999-12-31", "", map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
body = readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("admin price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
for _, expected := range []string{
|
|
"13900005555",
|
|
"半月板损伤",
|
|
"上海市 上海市 浦东新区",
|
|
"待派单",
|
|
"<th>创建时间</th>",
|
|
"<th>最近提交时间</th>",
|
|
"<th>更新时间</th>",
|
|
"<th>备注</th>",
|
|
"<th>设置</th>",
|
|
"共 1 条咨询",
|
|
`name="phone" value="13900005555"`,
|
|
`<select id="status" name="status">`,
|
|
`<option value="" selected>全部状态</option>`,
|
|
`name="time_from" value="2000-01-01" type="date"`,
|
|
`name="time_to" value="2999-12-31" type="date"`,
|
|
`class="status-pill status-pending">待派单</span>`,
|
|
`class="button secondary small">设置</summary>`,
|
|
`href="/admin/price-inquiries">全部</a>`,
|
|
} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Fatalf("admin price inquiry page missing %q: %s", expected, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "<h1>价格咨询派单</h1>") || strings.Contains(body, "咨询单 ID") {
|
|
t.Fatalf("admin price inquiry page rendered removed title or ID column: %s", body)
|
|
}
|
|
for _, removed := range []string{`name="created_from"`, `name="created_to"`, `name="updated_from"`, `name="updated_to"`} {
|
|
if strings.Contains(body, removed) {
|
|
t.Fatalf("admin price inquiry page rendered removed time filter %q: %s", removed, body)
|
|
}
|
|
}
|
|
if strings.Count(body, "<strong>13900005555</strong>") != 1 {
|
|
t.Fatalf("admin price inquiry page rendered duplicate rows: %s", body)
|
|
}
|
|
|
|
settings := url.Values{
|
|
"id": {createdInquiry.ID},
|
|
"area": {"浦东"},
|
|
"phone": {"13900005555"},
|
|
"status_filter": {"pending"},
|
|
"time_from": {"2000-01-01"},
|
|
"time_to": {"2999-12-31"},
|
|
"status": {"assigned"},
|
|
"remark": {"已派给浦东门诊"},
|
|
}
|
|
resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{
|
|
"Cookie": cookie,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("save price inquiry settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp))
|
|
}
|
|
if location := resp.Header.Get("Location"); !strings.Contains(location, "area=%E6%B5%A6%E4%B8%9C") || !strings.Contains(location, "phone=13900005555") || !strings.Contains(location, "status=pending") || !strings.Contains(location, "time_from=2000-01-01") {
|
|
t.Fatalf("save price inquiry settings Location = %q, want preserved filters", location)
|
|
}
|
|
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?area=浦东&phone=13900005555&status=assigned&time_from=2000-01-01&time_to=2999-12-31", "", map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
body = readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `class="status-pill status-assigned">已经派单</span>`) || !strings.Contains(body, "已派给浦东门诊") || !strings.Contains(body, `<option value="assigned" selected>已经派单</option>`) {
|
|
t.Fatalf("saved price inquiry settings not rendered, status/body = %d/%s", resp.StatusCode, body)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?status=completed", "", map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
body = readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("admin status-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") {
|
|
t.Fatalf("admin status filter did not remove non-matching inquiries: %s", body)
|
|
}
|
|
|
|
settings.Set("status", "completed")
|
|
settings.Set("remark", "用户已联系,等待到院")
|
|
resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{
|
|
"Cookie": cookie,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("overwrite price inquiry settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp))
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?area=浦东", "", map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
body = readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("updated admin price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
for _, expected := range []string{"已跟进", "用户已联系,等待到院"} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Fatalf("updated admin price inquiry page missing %q: %s", expected, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "已派给浦东门诊") {
|
|
t.Fatalf("old price inquiry remark was not overwritten: %s", body)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?phone=13999999999", "", map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
body = readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("admin phone-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") {
|
|
t.Fatalf("admin phone filter did not remove non-matching inquiries: %s", body)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?time_from=2999-01-01", "", map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
body = readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("admin time-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") {
|
|
t.Fatalf("admin time filters did not remove out-of-range inquiries: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestAdminPriceInquiryPagination(t *testing.T) {
|
|
app, closeDB := newTestAdminApp(t)
|
|
defer closeDB()
|
|
|
|
var firstInquiryID string
|
|
for i := 0; i < 21; i++ {
|
|
phone := fmt.Sprintf("139100100%02d", i)
|
|
id := createTestPriceInquiry(t, app, fmt.Sprintf("price_inquiry_page_%02d", i), phone)
|
|
if i == 0 {
|
|
firstInquiryID = id
|
|
}
|
|
}
|
|
|
|
cookie := loginAdmin(t, app)
|
|
resp := doRequest(t, app, http.MethodGet, "/admin/price-inquiries?page_size=20", "", map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
body := readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("first page status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
for _, expected := range []string{
|
|
"共 21 条咨询,第 1 / 2 页",
|
|
`<option value="20" selected>20 条/页</option>`,
|
|
`<input type="hidden" name="page" value="1">`,
|
|
`<input type="hidden" name="page_size" value="20">`,
|
|
`href="/admin/price-inquiries?page=2&page_size=20">下一页</a>`,
|
|
} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Fatalf("first page missing pagination element %q: %s", expected, body)
|
|
}
|
|
}
|
|
if strings.Count(body, `class="button secondary small">设置</summary>`) != 20 {
|
|
t.Fatalf("first page rendered wrong row count: %s", body)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?page=2&page_size=20", "", map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
body = readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("second page status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
for _, expected := range []string{
|
|
"共 21 条咨询,第 2 / 2 页",
|
|
`href="/admin/price-inquiries?page_size=20">上一页</a>`,
|
|
`<span class="button secondary small disabled">下一页</span>`,
|
|
} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Fatalf("second page missing pagination element %q: %s", expected, body)
|
|
}
|
|
}
|
|
if strings.Count(body, `class="button secondary small">设置</summary>`) != 1 {
|
|
t.Fatalf("second page rendered wrong row count: %s", body)
|
|
}
|
|
|
|
settings := url.Values{
|
|
"id": {firstInquiryID},
|
|
"status": {"assigned"},
|
|
"remark": {"分页保留备注"},
|
|
"page": {"2"},
|
|
"page_size": {"20"},
|
|
}
|
|
resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{
|
|
"Cookie": cookie,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("save paged settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp))
|
|
}
|
|
location := resp.Header.Get("Location")
|
|
if !strings.Contains(location, "page=2") || !strings.Contains(location, "page_size=20") || !strings.Contains(location, "message=") {
|
|
t.Fatalf("save paged settings Location = %q, want preserved pagination", location)
|
|
}
|
|
}
|
|
|
|
func TestAdminPriceInquiryRejectsInvalidPagination(t *testing.T) {
|
|
app, closeDB := newTestAdminApp(t)
|
|
defer closeDB()
|
|
|
|
cookie := loginAdmin(t, app)
|
|
for _, target := range []string{
|
|
"/admin/price-inquiries?page=0",
|
|
"/admin/price-inquiries?page=abc",
|
|
"/admin/price-inquiries?page_size=10",
|
|
"/admin/price-inquiries?page_size=abc",
|
|
} {
|
|
resp := doRequest(t, app, http.MethodGet, target, "", map[string]string{
|
|
"Cookie": cookie,
|
|
"Accept": "text/html",
|
|
})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("%s status = %d, want %d: %s", target, resp.StatusCode, http.StatusBadRequest, readBody(t, resp))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWechatPhoneCodeDoesNotBindMockPhone(t *testing.T) {
|
|
app, closeDB := newTestAdminAppWithConfig(t, config.Config{WeChatAPIBase: "https://unused.invalid"})
|
|
defer closeDB()
|
|
session := struct{ User struct{ Openid string } }{}
|
|
session.User.Openid = "test-user"
|
|
var resp *http.Response
|
|
|
|
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phoneCode":"test-phone-code"}`, map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
defer resp.Body.Close()
|
|
body := readBody(t, resp)
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("phone code bind status = %d, want %d: %s", resp.StatusCode, http.StatusInternalServerError, body)
|
|
}
|
|
if !strings.Contains(body, "GUAHAO_WECHAT_APPID") || !strings.Contains(body, "GUAHAO_WECHAT_SECRET") {
|
|
t.Fatalf("phone code bind error = %s, want missing WeChat configuration error", body)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phone":"13900002222"}`, map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
defer resp.Body.Close()
|
|
body = readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("explicit phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
if strings.Contains(body, "13800005678") || !strings.Contains(body, "13900002222") {
|
|
t.Fatalf("explicit phone bind body = %s, want real provided phone only", body)
|
|
}
|
|
}
|
|
|
|
func TestWechatPhoneBindCreatesMissingDemoUser(t *testing.T) {
|
|
app, closeDB := newTestAdminApp(t)
|
|
defer closeDB()
|
|
|
|
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"demo_openid_demo","phone":"13900004444"}`, map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
defer resp.Body.Close()
|
|
body := readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
if !strings.Contains(body, `"openid":"demo_openid_demo"`) || !strings.Contains(body, "13900004444") {
|
|
t.Fatalf("phone bind body = %s, want created demo user with bound phone", body)
|
|
}
|
|
}
|
|
|
|
func TestWechatPhoneCodeExchangesAndBindsRealPhone(t *testing.T) {
|
|
var tokenCalls atomic.Int64
|
|
var phoneCalls atomic.Int64
|
|
wechat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/sns/jscode2session":
|
|
w.Write([]byte(`{"openid":"phone-exchange-user"}`))
|
|
case "/cgi-bin/token":
|
|
tokenCalls.Add(1)
|
|
if r.URL.Query().Get("grant_type") != "client_credential" {
|
|
t.Fatalf("grant_type = %q, want client_credential", r.URL.Query().Get("grant_type"))
|
|
}
|
|
if r.URL.Query().Get("appid") != "test-appid" || r.URL.Query().Get("secret") != "test-secret" {
|
|
t.Fatalf("unexpected appid/secret query: %s", r.URL.RawQuery)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"test-token","expires_in":7200}`))
|
|
case "/wxa/business/getuserphonenumber":
|
|
phoneCalls.Add(1)
|
|
if r.URL.Query().Get("access_token") != "test-token" {
|
|
t.Fatalf("access_token = %q, want test-token", r.URL.Query().Get("access_token"))
|
|
}
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode phone request body: %v", err)
|
|
}
|
|
if body.Code != "real-phone-code" {
|
|
t.Fatalf("phone code = %q, want real-phone-code", body.Code)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok","phone_info":{"phoneNumber":"13900003333","purePhoneNumber":"13900003333","countryCode":"86"}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer wechat.Close()
|
|
|
|
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
|
|
WeChatAppID: "test-appid",
|
|
WeChatAppSecret: "test-secret",
|
|
WeChatAPIBase: wechat.URL,
|
|
})
|
|
defer closeDB()
|
|
|
|
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", `{"code":"phone_exchange"}`, map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("session status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp))
|
|
}
|
|
var session struct {
|
|
User struct {
|
|
Openid string `json:"openid"`
|
|
} `json:"user"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
|
t.Fatalf("decode session: %v", err)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phoneCode":"real-phone-code"}`, map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
defer resp.Body.Close()
|
|
body := readBody(t, resp)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
if !strings.Contains(body, "13900003333") || strings.Contains(body, "13800005678") {
|
|
t.Fatalf("phone bind body = %s, want exchanged real phone only", body)
|
|
}
|
|
if tokenCalls.Load() != 1 || phoneCalls.Load() != 1 {
|
|
t.Fatalf("tokenCalls/phoneCalls = %d/%d, want 1/1", tokenCalls.Load(), phoneCalls.Load())
|
|
}
|
|
}
|
|
|
|
func newTestAdminApp(t *testing.T) (*fiber.App, func()) {
|
|
return newTestAdminAppWithConfig(t, config.Config{})
|
|
}
|
|
|
|
func newTestAdminAppWithConfig(t *testing.T, cfgOverride config.Config) (*fiber.App, func()) {
|
|
t.Helper()
|
|
database, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
schemaPath := filepath.Join("..", "..", "migrations", "schema.sql")
|
|
if err := service.ApplySchema(ctx, database, schemaPath); err != nil {
|
|
database.Close()
|
|
t.Fatalf("apply schema: %v", err)
|
|
}
|
|
log := logrus.New()
|
|
log.SetOutput(io.Discard)
|
|
svc := service.New(database, log)
|
|
cfg := cfgOverride
|
|
cfg.AllowOrigins = "*"
|
|
if cfg.WeChatAPIBase == "" {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/sns/jscode2session" {
|
|
json.NewEncoder(w).Encode(map[string]string{"openid": "test-openid-" + r.URL.Query().Get("js_code")})
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
cfg.WeChatAPIBase = server.URL
|
|
cfg.WeChatAppID = "test-appid"
|
|
cfg.WeChatAppSecret = "test-secret"
|
|
}
|
|
if cfg.AdminUsername == "" {
|
|
cfg.AdminUsername = "test-admin"
|
|
}
|
|
if cfg.AdminPassword == "" {
|
|
cfg.AdminPassword = "test-password"
|
|
}
|
|
if cfg.AdminSessionSecret == "" {
|
|
cfg.AdminSessionSecret = testAdminSessionSecret
|
|
}
|
|
return New(cfg, svc, log), func() { database.Close() }
|
|
}
|
|
|
|
func createTestPriceInquiry(t *testing.T, app *fiber.App, code, phone string) string {
|
|
t.Helper()
|
|
|
|
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", fmt.Sprintf(`{"code":%q}`, code), map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
var session struct {
|
|
User struct {
|
|
Openid string `json:"openid"`
|
|
} `json:"user"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
|
resp.Body.Close()
|
|
t.Fatalf("decode session: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("session status = %d, want %d", resp.StatusCode, http.StatusOK)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", fmt.Sprintf(`{"openid":%q,"phone":%q}`, session.User.Openid, phone), map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
body := readBody(t, resp)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
|
}
|
|
|
|
resp = doRequest(t, app, http.MethodPost, "/api/price-inquiries", fmt.Sprintf(`{"openid":%q,"province":"上海市","city":"上海市","district":"浦东新区","project_name":"半月板损伤"}`, session.User.Openid), map[string]string{
|
|
"Content-Type": "application/json",
|
|
})
|
|
body = readBody(t, resp)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusCreated, body)
|
|
}
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &created); err != nil {
|
|
t.Fatalf("decode price inquiry: %v", err)
|
|
}
|
|
if created.ID == "" {
|
|
t.Fatalf("price inquiry missing id: %s", body)
|
|
}
|
|
return created.ID
|
|
}
|
|
|
|
func loginAdmin(t *testing.T, app *fiber.App) string {
|
|
t.Helper()
|
|
return adminSessionCookieHeader(config.Config{AdminSessionSecret: testAdminSessionSecret}, adminUser{
|
|
Subject: "test-admin",
|
|
DisplayName: "test-admin",
|
|
})
|
|
}
|
|
|
|
func adminSessionCookieHeader(cfg config.Config, user adminUser) string {
|
|
ui := newAdminUI(cfg, nil)
|
|
token := ui.signSession(user, time.Now().Add(8*time.Hour))
|
|
return adminSessionCookie + "=" + token
|
|
}
|
|
|
|
func doRequest(t *testing.T, app *fiber.App, method, target, body string, headers map[string]string) *http.Response {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
|
return testRequest(t, app, req, headers)
|
|
}
|
|
|
|
func testRequest(t *testing.T, app *fiber.App, req *http.Request, headers map[string]string) *http.Response {
|
|
t.Helper()
|
|
for key, value := range headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", req.Method, req.URL.String(), err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func readBody(t *testing.T, resp *http.Response) string {
|
|
t.Helper()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
return string(body)
|
|
}
|