package main import ( "bytes" "encoding/base64" "encoding/json" "image" "image/color" "image/png" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // pngDataURL builds a w-by-h PNG dataURL; opaque=false adds transparency // so tests can hit both the JPEG (opaque) and PNG (alpha) encode branches. func pngDataURL(t *testing.T, w, h int, opaque bool) string { t.Helper() img := image.NewNRGBA(image.Rect(0, 0, w, h)) a := uint8(255) if !opaque { a = 128 } for x := 0; x < w; x++ { for y := 0; y < h; y++ { img.Set(x, y, color.NRGBA{R: 200, G: 90, B: 60, A: a}) } } var buf bytes.Buffer if e := png.Encode(&buf, img); e != nil { t.Fatal(e) } return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) } func TestSaveContentImageBase64Mode(t *testing.T) { a := newSyncTestApp(t) // Opaque large image: downscaled to contentImgMaxEdge and re-encoded as JPEG. got, e := a.SaveContentImage(pngDataURL(t, 2200, 1100, true)) if e != nil { t.Fatal(e) } if !strings.HasPrefix(got, "data:image/jpeg;base64,") { t.Fatalf("opaque image should become jpeg dataURL: %.40s", got) } img := decodeDataURL(t, got) if b := img.Bounds(); b.Dx() != contentImgMaxEdge { t.Fatalf("expected width %d, got %d", contentImgMaxEdge, b.Dx()) } // Semi-transparent image must stay PNG to keep the alpha channel. got, e = a.SaveContentImage(pngDataURL(t, 40, 40, false)) if e != nil { t.Fatal(e) } if !strings.HasPrefix(got, "data:image/png;base64,") { t.Fatalf("transparent image should stay png: %.40s", got) } } func TestSaveContentImagePathMode(t *testing.T) { a := newSyncTestApp(t) st, _ := a.store.Settings() st.ImageMode = "path" if e := a.store.SaveSettings(st); e != nil { t.Fatal(e) } got, e := a.SaveContentImage(pngDataURL(t, 64, 64, true)) if e != nil { t.Fatal(e) } if strings.Contains(got, "\\") || !strings.Contains(got, "/images/img-") { t.Fatalf("path mode should return forward-slash path in images dir: %s", got) } if _, e := os.Stat(filepath.FromSlash(got)); e != nil { t.Fatalf("saved image file missing: %v", e) } // Files written in path mode must be readable back as dataURL for rendering. dataURL, e := a.ReadContentImageAsDataURL(filepath.FromSlash(got)) if e != nil { t.Fatal(e) } if !strings.HasPrefix(dataURL, "data:image/") { t.Fatalf("unexpected dataURL: %.40s", dataURL) } } func TestSaveContentImageRejectsBadInput(t *testing.T) { a := newSyncTestApp(t) for _, bad := range []string{"", "hello", "data:text/plain;base64,aGk=", "data:image/png;base64,!!!"} { if _, e := a.SaveContentImage(bad); e == nil { t.Fatalf("input %q should fail", bad) } } } func TestSettingsImageModeRoundTrip(t *testing.T) { s, e := OpenStore(filepath.Join(t.TempDir(), "im.db")) if e != nil { t.Fatal(e) } defer s.db.Close() st, _ := s.Settings() if st.ImageMode != "base64" { t.Fatalf("default should be base64, got %q", st.ImageMode) } st.ImageMode = "path" if e = s.SaveSettings(st); e != nil { t.Fatal(e) } st, _ = s.Settings() if st.ImageMode != "path" { t.Fatalf("imageMode not persisted, got %q", st.ImageMode) } // server 是合法模式(上传到 nl-pms-api)。 st.ImageMode = "server" if e = s.SaveSettings(st); e != nil { t.Fatal(e) } st, _ = s.Settings() if st.ImageMode != "server" { t.Fatalf("server mode should persist, got %q", st.ImageMode) } // Invalid value falls back to the default. st.ImageMode = "oss" if e = s.SaveSettings(st); e != nil { t.Fatal(e) } st, _ = s.Settings() if st.ImageMode != "base64" { t.Fatalf("invalid mode should fall back to base64, got %q", st.ImageMode) } } // TestSaveContentImageServerMode 用本地 httptest 假扮 nl-pms-api: // server 模式下内容图上传成功返回 http URL,服务器不可达时报错而非静默降级。 func TestSaveContentImageServerMode(t *testing.T) { a := newSyncTestApp(t) var gotAuth, gotKind string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/api/v1/files" { http.NotFound(w, r) return } gotAuth = r.Header.Get("Authorization") if e := r.ParseMultipartForm(32 << 20); e != nil { http.Error(w, "bad form", 400) return } gotKind = r.FormValue("kind") w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":1,"name":"2026/08/13/abc.jpg","url":"` + serverBase(r) + `/files/2026/08/13/abc.jpg"}`)) })) defer srv.Close() cfg, _ := json.Marshal(FileStorageConfig{Mode: "server", BaseURL: srv.URL, APIKey: "k1"}) if e := a.store.SetMeta(fileStorageKey, string(cfg)); e != nil { t.Fatal(e) } st, _ := a.store.Settings() st.ImageMode = "server" if e := a.store.SaveSettings(st); e != nil { t.Fatal(e) } got, e := a.SaveContentImage(pngDataURL(t, 64, 64, true)) if e != nil { t.Fatal(e) } if !strings.HasPrefix(got, "http") || !strings.Contains(got, "/files/") { t.Fatalf("server mode should return http url, got %q", got) } if gotAuth != "Bearer k1" { t.Fatalf("missing bearer key, got %q", gotAuth) } if gotKind != "content" { t.Fatalf("kind should be content, got %q", gotKind) } // 服务器关闭后上传必须报错(不能静默转 base64)。 srv.Close() if _, e := a.SaveContentImage(pngDataURL(t, 32, 32, true)); e == nil { t.Fatal("upload to dead server should fail") } } func serverBase(r *http.Request) string { return "http://" + r.Host }