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"])) +}