package main import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "testing" ) func listTrash(t *testing.T, s *Server) []trashItem { t.Helper() rec := httptest.NewRecorder() s.handleListTrash(rec, httptest.NewRequest(http.MethodGet, "/api/trash", nil)) if rec.Code != http.StatusOK { t.Fatalf("list trash: code=%d", rec.Code) } var items []trashItem if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { t.Fatalf("decode trash: %v", err) } return items } func TestTrashListAndRestore(t *testing.T) { s, content, _ := newTestServer(t) // Delete foo, then it should show up in the trash. rec := httptest.NewRecorder() s.handleDelete(rec, form(url.Values{"slug": {"foo"}})) if rec.Code != http.StatusOK { t.Fatalf("delete: %d", rec.Code) } items := listTrash(t, s) if len(items) != 1 { t.Fatalf("trash has %d items, want 1", len(items)) } it := items[0] if it.Slug != "foo" || !it.HasMeta || it.Image == "" { t.Errorf("trash item wrong: %+v", it) } // Restore it. rec = httptest.NewRecorder() s.handleRestore(rec, form(url.Values{"id": {it.ID}})) if rec.Code != http.StatusOK { t.Fatalf("restore: code=%d body=%s", rec.Code, rec.Body) } for _, p := range []string{"foo.png", "foo.toml"} { if _, err := os.Stat(filepath.Join(content, p)); err != nil { t.Errorf("restored %s missing: %v", p, err) } } if items := listTrash(t, s); len(items) != 0 { t.Errorf("trash not empty after restore: %+v", items) } // Caption survived the round trip. if readMeta(t, content, "foo").Caption != "Foo" { t.Error("restored sidecar lost its caption") } } func TestRestoreRefusesLiveCollision(t *testing.T) { s, content, _ := newTestServer(t) rec := httptest.NewRecorder() s.handleDelete(rec, form(url.Values{"slug": {"foo"}})) if rec.Code != http.StatusOK { t.Fatalf("delete: %d", rec.Code) } id := listTrash(t, s)[0].ID // A new live post now occupies the slug. writePNG(t, filepath.Join(content, "foo.png")) rec = httptest.NewRecorder() s.handleRestore(rec, form(url.Values{"id": {id}})) if rec.Code != http.StatusConflict { t.Errorf("restore over live post: code=%d, want 409", rec.Code) } // The trashed copy is left intact for a manual decision. if len(listTrash(t, s)) != 1 { t.Error("trashed item should remain after refused restore") } } func TestEmptyTrash(t *testing.T) { s, _, _ := newTestServer(t) // Trash both posts. rec := httptest.NewRecorder() s.handleDelete(rec, form(url.Values{"slugs": {"foo,bar"}})) if rec.Code != http.StatusOK { t.Fatalf("delete: %d", rec.Code) } items := listTrash(t, s) if len(items) != 2 { t.Fatalf("trash has %d, want 2", len(items)) } // Empty one by id. rec = httptest.NewRecorder() s.handleEmptyTrash(rec, form(url.Values{"id": {items[0].ID}})) if rec.Code != http.StatusOK { t.Fatalf("empty one: %d", rec.Code) } if len(listTrash(t, s)) != 1 { t.Error("expected 1 item left after deleting one") } // Empty all. rec = httptest.NewRecorder() s.handleEmptyTrash(rec, form(url.Values{"all": {"1"}})) if rec.Code != http.StatusOK { t.Fatalf("empty all: %d", rec.Code) } if len(listTrash(t, s)) != 0 { t.Error("trash not empty after all") } } func TestSplitTrashIDRejectsJunk(t *testing.T) { cases := map[string]bool{ "foo.20260102-150405": true, "foo.bar.20260102-150405": false, // slug "foo.bar" has a dot → rejected "../x.20260102-150405": false, // traversal "foo.2026": false, // malformed stamp "foo": false, // no stamp "": false, } for id, want := range cases { if _, _, ok := splitTrashID(id); ok != want { t.Errorf("splitTrashID(%q) = %v, want %v", id, ok, want) } } }