package admin import ( "encoding/json" "errors" "net/http" "os" "path/filepath" "strconv" "strings" "testing" "oneblog/internal/model" "oneblog/internal/storage" "oneblog/internal/store" ) func itoa(n int64) string { return strconv.FormatInt(n, 10) } // seed 放一个本地 blob + 一行 files,再按 body 建一篇引用它的短文(body 为空表示不引用)。 func seed(t *testing.T, a *API, key, body string) model.File { t.Helper() if err := a.Blobs.Put(t.Context(), key, strings.NewReader("pngbytes"), 8, "image/png"); err != nil { t.Fatal(err) } f, err := a.Store.CreateFile(model.File{ Key: key, Name: filepath.Base(key), Mime: "image/png", Size: 8, SHA256: strings.Repeat("f", 64), Store: "local", }) if err != nil { t.Fatal(err) } if body != "" { if _, err := a.Store.Create(model.PostInput{ Kind: model.KindShort, Slug: "s-" + key, ContentMd: body, Status: model.StatusPublished, }); err != nil { t.Fatal(err) } } return f } func TestFileRefsEndpoint(t *testing.T) { a, _ := newTestAPI(t) a.Blobs = storage.NewLocal(t.TempDir()) f := seed(t, a, "2026/09/aaa.png", `看图 ![](/uploads/2026/09/aaa.png)`) // 顺手把站主头像也指到同一张图,refs 要把这一路也报出来 if err := a.Store.SetSetting("owner_avatar_key", f.Key); err != nil { t.Fatal(err) } rec := doAs(t, a, http.MethodGet, "/api/admin/files/"+itoa(f.ID)+"/refs", "") if rec.Code != http.StatusOK { t.Fatalf("got %d %s", rec.Code, rec.Body.String()) } var v struct { Key string `json:"key"` Count int `json:"count"` Items []model.FileRef `json:"items"` } if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { t.Fatal(err) } if v.Key != f.Key || v.Count != 2 || len(v.Items) != 2 { t.Fatalf("refs 不对: %+v", v) } var kinds = map[string]bool{} for _, x := range v.Items { kinds[x.Kind] = true } if !kinds["post"] || !kinds["avatar"] { t.Fatalf("缺引用类型: %+v", v.Items) } // 不存在的文件:404 而不是空列表 rec = doAs(t, a, http.MethodGet, "/api/admin/files/9999/refs", "") if rec.Code != http.StatusNotFound { t.Fatalf("want 404, got %d", rec.Code) } // 乱七八糟的子路径不收 rec = doAs(t, a, http.MethodGet, "/api/admin/files/1/nope", "") if rec.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", rec.Code) } } // 有引用时默认挡住,而且挡住之后 blob 和行都得还在(可重试)。 func TestFileDeleteBlockedByRefs(t *testing.T) { a, _ := newTestAPI(t) dir := t.TempDir() a.Blobs = storage.NewLocal(dir) f := seed(t, a, "2026/09/bbb.png", `![](/uploads/2026/09/bbb.png)`) rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "") if rec.Code != http.StatusConflict { t.Fatalf("want 409, got %d %s", rec.Code, rec.Body.String()) } var e struct { Error string `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil { t.Fatal(err) } if !strings.Contains(e.Error, "1 处引用") { t.Fatalf("错误消息没报引用数: %q", e.Error) } if _, err := os.Stat(filepath.Join(dir, "2026/09/bbb.png")); err != nil { t.Fatalf("被挡住时不该动存储: %v", err) } if _, err := a.Store.GetFile(f.ID); err != nil { t.Fatalf("被挡住时不该删行: %v", err) } } // 明确带 force=1 才真删:行、blob 一起走。 func TestFileDeleteForceProceeds(t *testing.T) { a, _ := newTestAPI(t) dir := t.TempDir() a.Blobs = storage.NewLocal(dir) f := seed(t, a, "2026/09/ccc.png", `![](/uploads/2026/09/ccc.png)`) rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID)+"?force=1", "") if rec.Code != http.StatusOK { t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String()) } if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) { t.Fatalf("行应已删除, got %v", err) } if _, err := os.Stat(filepath.Join(dir, "2026/09/ccc.png")); !os.IsNotExist(err) { t.Fatalf("blob 应已删除, got %v", err) } } // 没被引用的文件不用 force 也能删(守卫不能把所有删除都挡死)。 func TestFileDeleteUnreferenced(t *testing.T) { a, _ := newTestAPI(t) a.Blobs = storage.NewLocal(t.TempDir()) f := seed(t, a, "2026/09/ddd.png", "") rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "") if rec.Code != http.StatusOK { t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String()) } if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) { t.Fatalf("行应已删除, got %v", err) } }