From f847769698c155a92d3fe5a2043a8da3927b8fc3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 09:49:57 +0800 Subject: [PATCH] =?UTF-8?q?test(gateway):=20=E9=9D=A2=E6=9D=BF=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E5=88=97=E8=A1=A8=E8=BF=87=20status=20=E9=97=B8?= =?UTF-8?q?=E7=9A=84=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=9B=9E=E5=BD=92=EF=BC=88?= =?UTF-8?q?TIDY-1=20=E8=A1=A5=E8=AF=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实 sql.DB + 真实 memory.Store + 真实 HTTP handler:两条在册 ⇒ 面板 2 条; 软合并一条 ⇒ 只剩被保留那条;再归档另一条 ⇒ 200 + 非 null 空数组。 复核(主会话亲自):目标测试绿;用 overlay 把 store.List 的 status 闸 变异成恒真 ⇒ 该用例变红(证明它真的在测那条闸,而不是在测夹具)。 顺带确证一件口径:GET /admin/memories/reviews(审查留痕)**不该**过 status 闸 —— 留痕表本身无 status 概念(preinsert_schema.go:29-46),且它是软删行的 查看与撤销入口,加闸等于审计自我抹除。 --- .../gateway/admin_memory_status_gate_test.go | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 internal/services/gateway/admin_memory_status_gate_test.go diff --git a/internal/services/gateway/admin_memory_status_gate_test.go b/internal/services/gateway/admin_memory_status_gate_test.go new file mode 100644 index 0000000..bf4a844 --- /dev/null +++ b/internal/services/gateway/admin_memory_status_gate_test.go @@ -0,0 +1,139 @@ +package gateway + +// 本文件为**网关侧(面板读侧)的 status 闸**补证: +// `GET /api/v1/admin/memories` 经 `collectMemories` → memory.Store.List, +// 必须只显示**在册**(status='active')条目 —— 被软合并('merged')与被归档 +// ('archived')的行内容与行都还在表里,但绝不进面板列表。 +// +// 整理(tidy)与入库前审查(D3)只做软动作(只改 status,不删行、不改正文), +// 所以「清理有没有生效」完全取决于每条读路径过不过 status 闸。memory 包内已有 +// 读路径单测(internal/runtime/memory/status_gate_test.go);本文件补的是 +// **端到端**那一层:真实 sql.DB + 真实 memory.Store + 真实 HTTP handler。 + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" +) + +// newMemoryStoreAndDBForGateway 打开隔离内存库,同时返回可执行 SQL 的 *sql.DB +// 与构建在其上的真实 memory.Store。既有 newMemoryStoreForGateway 只给 store +// (db 句柄被封在 t.Cleanup 里),而本测试要直接改行 status,故两者都要。 +// db.Open(":memory:") 把连接池钳到单连接,因此经该 *sql.DB 的 UPDATE 与 store +// 的读命中同一个库。 +func newMemoryStoreAndDBForGateway(t *testing.T) (*sql.DB, *memory.Store) { + t.Helper() + ctx := context.Background() + d, err := db.Open(ctx, ":memory:", gwTestLogger()) + if err != nil { + t.Fatalf("open in-memory db: %v", err) + } + t.Cleanup(func() { _ = d.Close() }) + s, err := memory.NewStore(ctx, d, gwTestLogger()) + if err != nil { + t.Fatalf("memory.NewStore: %v", err) + } + return d, s +} + +// softDeleteMemoryForTest 直接改一行的 status,模拟 tidy / D3 的真实软动作: +// 只改 status,正文与行原样留着。行不存在时 RowsAffected != 1,直接失败, +// 以免测试在「其实没改到任何行」的静默状态下给出假绿。 +func softDeleteMemoryForTest(t *testing.T, d *sql.DB, id, status string) { + t.Helper() + res, err := d.ExecContext(context.Background(), + `UPDATE memory_items SET status = ? WHERE id = ?`, status, id) + if err != nil { + t.Fatalf("set status %q on %s: %v", status, id, err) + } + n, err := res.RowsAffected() + if err != nil { + t.Fatalf("rows affected for %s: %v", id, err) + } + if n != 1 { + t.Fatalf("set status %q on %s: rows=%d, want 1", status, id, n) + } +} + +// addMemoryViaAdmin 经 owner 写端点写入一条 persona 记忆并返回其 id。 +func addMemoryViaAdmin(t *testing.T, ts *httptest.Server, tok, content string) string { + t.Helper() + resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories", + map[string]any{"personaId": "cyrene", "content": content, "scope": "persona"}, tok) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("add %q status = %d, want 201 (body=%s)", content, resp.StatusCode, body) + } + var added struct { + ID string `json:"id"` + } + if err := json.Unmarshal(decodeBody(t, body)["data"], &added); err != nil { + t.Fatalf("decode add %q: %v (body=%s)", content, err, body) + } + if added.ID == "" { + t.Fatalf("add %q returned empty id (body=%s)", content, body) + } + return added.ID +} + +// listMemoriesViaAdmin 读管理面板记忆列表,返回已解码条目与原始响应体。 +func listMemoriesViaAdmin(t *testing.T, ts *httptest.Server, tok string) ([]MemorySummary, []byte) { + t.Helper() + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + var items []MemorySummary + if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil { + t.Fatalf("decode list: %v (body=%s)", err, body) + } + return items, body +} + +// TestAdminMemoriesListGatesSoftDeletedRows 端到端证明面板记忆列表过 status 闸: +// 软合并一条 ⇒ 它从列表消失且剩下的是被保留那条;再归档另一条 ⇒ 列表为空 +// (200 + 非 nil 空数组)。 +func TestAdminMemoriesListGatesSoftDeletedRows(t *testing.T) { + t.Parallel() + rawDB, store := newMemoryStoreAndDBForGateway(t) + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + keepID := addMemoryViaAdmin(t, ts, tok, "她喜欢喝冰美式") + dropID := addMemoryViaAdmin(t, ts, tok, "她讨厌香菜") + + // ① 两条都在册:面板两条都能看到(status 闸不该误伤 active 行)。 + items, body := listMemoriesViaAdmin(t, ts, tok) + if len(items) != 2 { + t.Fatalf("initial list len = %d, want 2 (items=%+v body=%s)", len(items), items, body) + } + t.Logf("① 两条在册 ⇒ 面板看到 %d 条: %+v", len(items), items) + + // ② 软合并 dropID(只改 status='merged',正文不动):面板只剩 keepID。 + softDeleteMemoryForTest(t, rawDB, dropID, "merged") + items, body = listMemoriesViaAdmin(t, ts, tok) + if len(items) != 1 { + t.Fatalf("after merged one: list len = %d, want 1 (items=%+v body=%s)", len(items), items, body) + } + if items[0].ID != keepID || items[0].Text != "她喜欢喝冰美式" { + t.Fatalf("after merged one: kept = %+v, want id=%s text=她喜欢喝冰美式", items[0], keepID) + } + t.Logf("② 软合并 %s ⇒ 面板只剩 %d 条: %+v", dropID, len(items), items) + + // ③ 再把 keepID 归档 ⇒ 面板列表为空(200 + 非 nil 空数组,而非 null)。 + softDeleteMemoryForTest(t, rawDB, keepID, "archived") + items, body = listMemoriesViaAdmin(t, ts, tok) + if len(items) != 0 { + t.Fatalf("after both soft-deleted: list len = %d, want 0 (items=%+v body=%s)", len(items), items, body) + } + if raw := string(decodeBody(t, body)["data"]); raw != "[]" { + t.Fatalf("empty list data = %s, want [] (non-nil empty array)", raw) + } + t.Logf("③ 再归档 %s ⇒ 面板空列表 data=%s", keepID, string(decodeBody(t, body)["data"])) +}