test(gateway): 面板记忆列表过 status 闸的端到端回归(TIDY-1 补证)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

真实 sql.DB + 真实 memory.Store + 真实 HTTP handler:两条在册 ⇒ 面板 2 条;
软合并一条 ⇒ 只剩被保留那条;再归档另一条 ⇒ 200 + 非 null 空数组。

复核(主会话亲自):目标测试绿;用 overlay 把 store.List 的 status 闸
变异成恒真 ⇒ 该用例变红(证明它真的在测那条闸,而不是在测夹具)。

顺带确证一件口径:GET /admin/memories/reviews(审查留痕)**不该**过 status 闸
—— 留痕表本身无 status 概念(preinsert_schema.go:29-46),且它是软删行的
查看与撤销入口,加闸等于审计自我抹除。
This commit is contained in:
AskaEth committed 2026-10-07 09:49:57 +08:00
1 parent 9126d653e5
commit f847769698
1 file changed
+139
@@ -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"]))
}