improved search
This commit is contained in:
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// handleMigrateDualRoots triggers the one-shot sweep that re-unifies
|
||||
@@ -54,3 +55,47 @@ func (s *Server) handleMigrateDomainScreen(w http.ResponseWriter, r *http.Reques
|
||||
"fixed": fixed,
|
||||
})
|
||||
}
|
||||
|
||||
// handleSweepLiteratureEchoes removes query echoes stored on literature cards:
|
||||
// `anilistid` on a site card, and `workcid` / position keys on any card
|
||||
// (meta-read docs/reading-model.md §7). See storage.SweepLiteratureEchoes for
|
||||
// why chapters and anchor cards' ids are deliberately left alone.
|
||||
//
|
||||
// Dry-run unless `?apply=true`: unlike the migrations above this one deletes
|
||||
// keys, so a bare call only reports what it would delete. An unparseable
|
||||
// `apply` is a 400 rather than a silent dry run, so a typo cannot pass for a
|
||||
// sweep that ran.
|
||||
//
|
||||
// Returns {success, dryRun, planned, fixed, byKey} — planned/fixed count
|
||||
// records, byKey counts fields per key. Safe to call repeatedly: a swept
|
||||
// record plans nothing on the next pass.
|
||||
func (s *Server) handleSweepLiteratureEchoes(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
apply := false
|
||||
if v := r.URL.Query().Get("apply"); v != "" {
|
||||
parsed, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "apply must be true or false")
|
||||
return
|
||||
}
|
||||
apply = parsed
|
||||
}
|
||||
|
||||
res, err := s.storage.SweepLiteratureEchoesWithTimeout(apply)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"dryRun": !apply,
|
||||
"planned": res.Planned,
|
||||
"fixed": res.Fixed,
|
||||
"byKey": res.ByKey,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
|
||||
"github.com/metazla/meta-core/internal/storage"
|
||||
)
|
||||
|
||||
// newSweepTestServer is a Server over miniredis with one literature card
|
||||
// carrying a copied `workcid`.
|
||||
func newSweepTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
c := storage.NewClient("")
|
||||
if err := c.Connect("redis://" + mr.Addr()); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
for k, v := range map[string]string{
|
||||
"domain": "literature",
|
||||
"fileType": "card",
|
||||
"workcid": "bagdsaaaua5qw42lmnfzxi3lbnztwcorrga2tonzy",
|
||||
} {
|
||||
if err := c.SetProperty("card-1", k, v); err != nil {
|
||||
t.Fatalf("seed %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
return &Server{storage: c}
|
||||
}
|
||||
|
||||
type sweepResponse struct {
|
||||
Success bool `json:"success"`
|
||||
DryRun bool `json:"dryRun"`
|
||||
Planned int `json:"planned"`
|
||||
Fixed int `json:"fixed"`
|
||||
ByKey map[string]int `json:"byKey"`
|
||||
}
|
||||
|
||||
func postSweep(t *testing.T, s *Server, query string) (*httptest.ResponseRecorder, sweepResponse) {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/sweep-literature-echoes"+query, nil)
|
||||
s.handleSweepLiteratureEchoes(rr, req)
|
||||
var body sweepResponse
|
||||
if rr.Code == http.StatusOK {
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode %s: %v", rr.Body.String(), err)
|
||||
}
|
||||
}
|
||||
return rr, body
|
||||
}
|
||||
|
||||
// A bare call must never delete: the default is the dry run.
|
||||
func TestSweepLiteratureEchoes_DefaultsToDryRun(t *testing.T) {
|
||||
s := newSweepTestServer(t)
|
||||
|
||||
rr, body := postSweep(t, s, "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !body.Success || !body.DryRun || body.Planned != 1 || body.Fixed != 0 || body.ByKey["workcid"] != 1 {
|
||||
t.Errorf("dry run = %+v", body)
|
||||
}
|
||||
if v, _ := s.storage.GetProperty("card-1", "workcid"); v == "" {
|
||||
t.Errorf("dry run deleted workcid")
|
||||
}
|
||||
|
||||
rr, body = postSweep(t, s, "?apply=true")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if body.DryRun || body.Planned != 1 || body.Fixed != 1 || body.ByKey["workcid"] != 1 {
|
||||
t.Errorf("apply = %+v", body)
|
||||
}
|
||||
if v, _ := s.storage.GetProperty("card-1", "workcid"); v != "" {
|
||||
t.Errorf("apply left workcid = %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// A typo must not pass for a sweep that ran.
|
||||
func TestSweepLiteratureEchoes_RejectsUnparseableApply(t *testing.T) {
|
||||
s := newSweepTestServer(t)
|
||||
|
||||
rr, _ := postSweep(t, s, "?apply=yes-please")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,9 @@ func (s *Server) setupRoutes() {
|
||||
// One-shot vocabulary sweep: domain film|tv → screen, workForm backfill
|
||||
// (METADATA_KEYS.md §14.17). Idempotent.
|
||||
s.router.HandleFunc("/api/admin/migrate-domain-screen", s.handleMigrateDomainScreen).Methods("POST")
|
||||
// One-shot sweep of query echoes on literature cards (meta-read
|
||||
// reading-model.md §7). Dry-run unless ?apply=true. Idempotent.
|
||||
s.router.HandleFunc("/api/admin/sweep-literature-echoes", s.handleSweepLiteratureEchoes).Methods("POST")
|
||||
|
||||
// SSE event streams. Mediated mirror of the Redis Streams; consumers
|
||||
// reach them over HTTP instead of touching Redis directly. Auth-bypass
|
||||
|
||||
Reference in New Issue
Block a user