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" ) func TestAdminLoginProtectsPagesAndRendersPriceInquiries(t *testing.T) { app, closeDB := newTestAdminAppWithConfig(t, config.Config{ AdminAuthMode: config.AdminAuthModeOIDC, AdminSessionSecret: "test-session-secret", OIDCIssuer: "https://auth.example.com/oidc", OIDCClientID: "test-client-id", OIDCClientSecret: "test-client-secret", OIDCRedirectURI: "http://127.0.0.1:9800/admin/oidc/callback", }) 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: "test-session-secret", }, adminUser{Subject: "logto-user-1", 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 页", ``, ``, `name="time_from" value="2000-01-01" type="date"`, `name="time_to" value="2999-12-31" type="date"`, `class="status-pill status-pending">待派单`, `class="button secondary small">设置`, `href="/admin/price-inquiries">全部`, } { if !strings.Contains(body, expected) { t.Fatalf("admin price inquiry page missing %q: %s", expected, body) } } if strings.Contains(body, "

价格咨询派单

") || 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, "13900005555") != 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">已经派单`) || !strings.Contains(body, "已派给浦东门诊") || !strings.Contains(body, ``) { 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 页", ``, ``, ``, `href="/admin/price-inquiries?page=2&page_size=20">下一页`, } { if !strings.Contains(body, expected) { t.Fatalf("first page missing pagination element %q: %s", expected, body) } } if strings.Count(body, `class="button secondary small">设置`) != 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">上一页`, `下一页`, } { if !strings.Contains(body, expected) { t.Fatalf("second page missing pagination element %q: %s", expected, body) } } if strings.Count(body, `class="button secondary small">设置`) != 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 := newTestAdminApp(t) defer closeDB() resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", `{"code":"phone_code_only"}`, 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":"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 "/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) if err := svc.Seed(ctx); err != nil { database.Close() t.Fatalf("seed: %v", err) } session, err := svc.Login(ctx, "admin_test") if err != nil { database.Close() t.Fatalf("login seeded user: %v", err) } if _, err := svc.BindPhone(ctx, session.User.Openid, "13900001111"); err != nil { database.Close() t.Fatalf("bind seeded phone: %v", err) } _, err = svc.CreateBooking(ctx, service.CreateBookingRequest{ OpenID: session.User.Openid, ProjectID: "project_fresh_clean", Date: time.Now().AddDate(0, 0, 1).Format("2006-01-02"), StartTime: "09:30", Phone: "13900001111", }) if err != nil { database.Close() t.Fatalf("create seeded booking: %v", err) } cfg := config.Config{ AllowOrigins: "*", UploadDir: t.TempDir(), WeChatAppID: cfgOverride.WeChatAppID, WeChatAppSecret: cfgOverride.WeChatAppSecret, WeChatAPIBase: cfgOverride.WeChatAPIBase, AdminAuthMode: config.AdminAuthModeNone, AdminSessionSecret: "test-session-secret", AdminPasswordChangeRedirectURL: cfgOverride.AdminPasswordChangeRedirectURL, OIDCIssuer: cfgOverride.OIDCIssuer, OIDCClientID: cfgOverride.OIDCClientID, OIDCClientSecret: cfgOverride.OIDCClientSecret, OIDCRedirectURI: cfgOverride.OIDCRedirectURI, OIDCScopes: cfgOverride.OIDCScopes, } if cfgOverride.AdminAuthMode != "" { cfg.AdminAuthMode = cfgOverride.AdminAuthMode } if cfgOverride.AdminSessionSecret != "" { cfg.AdminSessionSecret = cfgOverride.AdminSessionSecret } if cfg.AdminPasswordChangeRedirectURL == "" { cfg.AdminPasswordChangeRedirectURL = "https://gh.yqbmb.com/admin/price-inquiries" } 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 "" } 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) }