package web import ( "bytes" "context" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path" "path/filepath" "strconv" "strings" "testing" "time" ) // webpBytes 造一段带正确 RIFF/WEBP 魔数的假数据。服务端只校验文件头 // (转码在浏览器做),所以这里不需要真的 WebP 编码器。 func webpBytes(n int) []byte { b := make([]byte, 12+n) copy(b[0:4], "RIFF") copy(b[8:12], "WEBP") for i := 12; i < len(b); i++ { b[i] = byte(i) } return b } // postMultipart 按浏览器 FormData 的形态发一个 multipart 请求。 func postMultipart(t *testing.T, f *movementFixture, handler http.HandlerFunc, fields map[string]string, files map[string][]byte) *httptest.ResponseRecorder { t.Helper() var body bytes.Buffer w := multipart.NewWriter(&body) for k, v := range fields { if err := w.WriteField(k, v); err != nil { t.Fatal(err) } } for field, data := range files { part, err := w.CreateFormFile(field, "image.webp") if err != nil { t.Fatal(err) } if _, err := part.Write(data); err != nil { t.Fatal(err) } } if err := w.Close(); err != nil { t.Fatal(err) } req := httptest.NewRequest("POST", "/x", &body) req.Header.Set("Content-Type", w.FormDataContentType()) req = req.WithContext(f.ctx) rec := httptest.NewRecorder() handler(rec, req) return rec } // setMediaBackend 把「图片存哪边」临时改成 backend,测试结束再恢复。 // 多个测试共用一个库,不还原就会互相干扰(前一个设了 s3,后一个的本地上传就失败了)。 func setMediaBackend(t *testing.T, f *movementFixture, backend string) { t.Helper() var prev string if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(value->>'media_backend','local') FROM settings WHERE key='site'`).Scan(&prev); err != nil { t.Fatalf("读图片存储设置失败: %v", err) } write := func(v string) { t.Helper() if _, err := f.pg.ExecContext(f.ctx, `UPDATE settings SET value = jsonb_set(value, '{media_backend}', to_jsonb($1::text)) WHERE key='site'`, v); err != nil { t.Fatalf("写图片存储设置失败: %v", err) } } write(backend) t.Cleanup(func() { write(prev) }) } // TestPlaceImageUploadDelete 端到端跑一遍浏览器那条路: // multipart 上传 → 落库 + 落盘 → /media 取回同样的字节 → multipart 删除 → 两处都没了。 // // 这里刻意用 multipart 而不是 urlencoded:前端是用 FormData 发的, // 而「先 ParseForm 再读 multipart」会把字段读成空——这个坑真踩过(删除按钮点了没反应)。 func TestPlaceImageUploadDelete(t *testing.T) { f := newMovementFixture(t) f.cfg.DataDir = t.TempDir() // 图片写进临时目录,别脏了 storage/ setMediaBackend(t, f, "local") // 借一个现成的地点 var src int if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil { t.Fatalf("找不到地点: %v", err) } raw := webpBytes(64) rec := postMultipart(t, f, f.srv.adminPlaceImageUpload, map[string]string{"src_pk": fmt.Sprint(src), "w": "1200", "h": "900"}, map[string][]byte{"file": raw}) var up struct { OK bool `json:"ok"` ID int64 `json:"id"` URL string `json:"url"` Bytes int `json:"bytes"` Error string `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil { t.Fatalf("上传响应不是 JSON: %s", rec.Body.String()) } if !up.OK || up.Error != "" { t.Fatalf("上传失败: %s", rec.Body.String()) } if !strings.HasPrefix(up.URL, "/media/") { t.Fatalf("本地存储的地址应以 /media/ 开头,实际 %q", up.URL) } // 落盘 + 落库 full, err := f.srv.media.LocalPath(strings.TrimPrefix(up.URL, "/media/")) if err != nil { t.Fatalf("取路径失败: %v", err) } if got, err := os.ReadFile(full); err != nil || !bytes.Equal(got, raw) { t.Fatalf("落盘的字节与上传的不一致: %v", err) } // /media 直出同样的字节 rec = httptest.NewRecorder() req := httptest.NewRequest("GET", up.URL, nil).WithContext(f.ctx) f.srv.serveMedia(rec, req) if rec.Code != 200 { t.Fatalf("取图返回 %d", rec.Code) } if !bytes.Equal(rec.Body.Bytes(), raw) { t.Fatal("取回的字节与上传的不一致") } if ct := rec.Header().Get("Content-Type"); ct != "image/webp" { t.Errorf("Content-Type 不对: %q", ct) } // 非 WebP 必须被挡住(前端转码失败时会发来 PNG) rec = postMultipart(t, f, f.srv.adminPlaceImageUpload, map[string]string{"src_pk": fmt.Sprint(src)}, map[string][]byte{"file": append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 32)...)}) if !strings.Contains(rec.Body.String(), "只接受 WebP") { t.Errorf("非 WebP 没被拒绝: %s", rec.Body.String()) } // 删除:同样走 multipart rec = postMultipart(t, f, f.srv.adminPlaceImageDelete, map[string]string{"id": fmt.Sprint(up.ID)}, nil) if !strings.Contains(rec.Body.String(), `"ok":true`) { t.Fatalf("删除失败: %s", rec.Body.String()) } if _, err := os.Stat(full); !os.IsNotExist(err) { t.Errorf("文件没删掉: %v", err) } var n int if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM place_images WHERE id=$1`, up.ID).Scan(&n); err != nil { t.Fatal(err) } if n != 0 { t.Errorf("库里的记录没删掉") } } // TestPhotoImportWithImage 照片导入「存图」:地点与该行图片要在一次导入里都建好, // 并且图片挂在正确的 src_pk 上。 func TestPhotoImportWithImage(t *testing.T) { f := newMovementFixture(t) f.cfg.DataDir = t.TempDir() setMediaBackend(t, f, "local") raw := webpBytes(48) rec := postMultipart(t, f, f.srv.adminPhotosImport, map[string]string{ "n": "1", "coord": "wgs84", "keep_0": "on", "lat_0": "30.123456", "lon_0": "120.123456", "arrival_0": "2026-04-18T18:14", "name_0": "带图的导入点", "country_0": "CN", "timezone_0": "Asia/Shanghai", "image_w_0": "1200", "image_h_0": "900", }, map[string][]byte{"image_0": raw}) if rec.Code != http.StatusSeeOther { t.Fatalf("导入没返回重定向: %d %s", rec.Code, rec.Body.String()) } loc, err := url.QueryUnescape(rec.Header().Get("Location")) if err != nil { t.Fatal(err) } if strings.Contains(loc, "err=") { t.Fatalf("导入报错: %s", loc) } if !strings.Contains(loc, "配了 1 张图") { t.Errorf("结果消息里应提到配了图: %s", loc) } var src int var w, h int if err := f.pg.QueryRowContext(f.ctx, `SELECT p.src_pk, pi.width, pi.height FROM places p JOIN place_images pi ON pi.dataset_id=p.dataset_id AND pi.src_pk=p.src_pk WHERE p.dataset_id=$1 AND p.name='带图的导入点'`, f.dsID).Scan(&src, &w, &h); err != nil { t.Fatalf("配图没挂到地点上: %v", err) } if w != 1200 || h != 900 { t.Errorf("宽高没记对: %dx%d", w, h) } // 字节确实落盘了:目录是 <数据集>/<上传月份>/,文件名是 <地点号>-<哈希>.webp dir := filepath.Join(f.cfg.MediaDir(), fmt.Sprint(f.dsID), time.Now().Format("200601")) entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("月份目录不存在: %v", err) } if len(entries) != 1 { t.Fatalf("期望落盘 1 个文件,实际 %d", len(entries)) } if name := entries[0].Name(); !strings.HasPrefix(name, fmt.Sprint(src)+"-") || !strings.HasSuffix(name, ".webp") { t.Errorf("文件名应是 <地点号>-<哈希>.webp,实际 %q", name) } } // TestPlaceImageS3Backend 走一遍对象存储那条路:用 httptest 假装 S3 端点, // 断言请求打到「路径式 + 桶名 + 对象名」上,并带上了 SigV4 的 Authorization。 // 真去校验签名的是 media 包里的官方向量测试,这里盯的是接线对不对。 func TestPlaceImageS3Backend(t *testing.T) { f := newMovementFixture(t) f.cfg.DataDir = t.TempDir() var gotPath, gotAuth, gotCT string var gotBody []byte fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotAuth = r.Header.Get("Authorization") gotCT = r.Header.Get("Content-Type") gotBody, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusOK) })) defer fake.Close() f.cfg.S3Endpoint = fake.URL f.cfg.S3Region = "auto" f.cfg.S3Bucket = "pics" f.cfg.S3AccessKey = "AKIATEST" f.cfg.S3SecretKey = "secret" f.cfg.S3PublicBase = "https://img.example.com" setMediaBackend(t, f, "s3") var src int if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil { t.Fatalf("找不到地点: %v", err) } raw := webpBytes(32) rec := postMultipart(t, f, f.srv.adminPlaceImageUpload, map[string]string{"src_pk": fmt.Sprint(src)}, map[string][]byte{"file": raw}) var up struct { OK bool `json:"ok"` URL string `json:"url"` Error string `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil { t.Fatalf("上传响应不是 JSON: %s", rec.Body.String()) } if !up.OK || up.Error != "" { t.Fatalf("对象存储上传失败: %s", rec.Body.String()) } if !strings.HasPrefix(up.URL, "https://img.example.com/") { t.Errorf("对象存储的地址应当用 s3_public_base 拼,实际 %q", up.URL) } // 对象名 = <数据集>/<上传月份>/<地点号>-<内容哈希>.webp parts := strings.Split(strings.TrimPrefix(gotPath, "/pics/"), "/") if len(parts) != 3 { t.Fatalf("对象名层级不对(应为 数据集/月份/文件名): %q", gotPath) } if parts[0] != fmt.Sprint(f.dsID) { t.Errorf("第一层应是数据集号,实际 %q", parts[0]) } if len(parts[1]) != 6 || !strings.HasPrefix(parts[1], time.Now().Format("2006")) { t.Errorf("第二层应是上传月份(YYYYMM),实际 %q", parts[1]) } if !strings.HasPrefix(parts[2], fmt.Sprint(src)+"-") || !strings.HasSuffix(parts[2], ".webp") { t.Errorf("文件名应是 <地点号>-<哈希>.webp,实际 %q", parts[2]) } if !strings.Contains(gotAuth, "AWS4-HMAC-SHA256 Credential=AKIATEST/") || !strings.Contains(gotAuth, "/auto/s3/aws4_request") { t.Errorf("没带上 SigV4 签名: %q", gotAuth) } if gotCT != "image/webp" { t.Errorf("Content-Type 不对: %q", gotCT) } if !bytes.Equal(gotBody, raw) { t.Error("发出去的字节与上传的不一致") } // 存储被切到对象存储后,本地不应再落盘 entries, _ := os.ReadDir(path.Join(f.cfg.MediaDir(), fmt.Sprint(f.dsID))) if len(entries) != 0 { t.Errorf("切到对象存储后不该再写本地磁盘,却发现 %d 个条目", len(entries)) } } // TestVisitorPlacePageCarriesImageURLs 盯住「访客在详情页也能看到图片入口」。 // // 画廊的地址以前只从 #imgs-grid 反推,而那个缩略图墙是站长专属的 DOM,访客页里 // 根本没有——于是 syncGallery() 数到 0 张,把服务端已经渲染好的「图片 N」按钮 // remove 掉(表现成刷新后按钮闪一下就没了)。站长自己打开一切正常,所以只有按访客 // 身份把页面渲出来才验得到:**访客页必须自带地址列表,不能依赖那一块 DOM**。 func TestVisitorPlacePageCarriesImageURLs(t *testing.T) { f := newMovementFixture(t) var placeID int64 var srcPK int if err := f.pg.QueryRowContext(f.ctx, `SELECT id, src_pk FROM places WHERE dataset_id=$1 ORDER BY id LIMIT 1`, f.dsID).Scan(&placeID, &srcPK); err != nil { t.Fatalf("找不到地点: %v", err) } const key = "9/visitor-page-test.webp" // 这里只验发下去的地址,不验能否取回字节,所以不用真的写文件 if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO place_images (dataset_id, src_pk, storage, object_key, width, height, bytes, position) VALUES ($1,$2,'local',$3,1200,900,1024,0) ON CONFLICT (dataset_id, src_pk, object_key) DO NOTHING`, f.dsID, srcPK, key); err != nil { t.Fatalf("插入图片记录失败: %v", err) } t.Cleanup(func() { f.pg.ExecContext(f.ctx, `DELETE FROM place_images WHERE dataset_id=$1 AND object_key=$2`, f.dsID, key) }) req := httptest.NewRequest("GET", "/places/x", nil) req = req.WithContext(context.Background()) // 访客 = 上下文里没有登录态 req.SetPathValue("id", strconv.FormatInt(placeID, 10)) rec := httptest.NewRecorder() f.srv.placePage(rec, req) body := rec.Body.String() if rec.Code != http.StatusOK { t.Fatalf("访客访问详情页返回 %d:%s", rec.Code, firstLine(body)) } if !strings.Contains(body, `id="imgs-open"`) { t.Error("访客页面里没有「图片」按钮") } want := f.srv.media.PublicURL("local", key) if !strings.Contains(body, want) { t.Errorf("访客页面没有把图片地址发给浏览器(缺 %s)", want) } }