package memory import ( "context" "sort" "strings" "testing" "time" ) // 本文件是**读侧时效闸**的回归(design/03 §9.6 / §11.2)。 // // 判据:status='active' ∧ (expires_at = '' ∨ expires_at > now) ∧ (valid_to = '' ∨ valid_to > now), // 它必须同时出现在**四条列表读路径**上(Store.ListByPersona / ListScope / ListProject / List), // 因为记忆读入口有 8 个而底层只有这四个;Retriever.visibleCandidates 组合调用其中两条, // 因此检索入口自动继承同一判据(本条亦有测试)。任何一条漏闸 = 过期记忆照样进上下文、 // 照样出现在 admin 面板(design §11.2 的死角分析)。 // setItemExpiryForTest 直接改一行的时效列,模拟整理 / D3 第五态落下的时效裁定, // 或「事实已失效」的 valid_to 标记。走 SQL 而不是走写入路径,是为了让本条只验证 // **读侧**:写入侧(Add 真的把 ExpiresAt 落成 expires_at 列)由本文件末条测试单独钉。 func setItemExpiryForTest(t *testing.T, s *Store, id, expiresAt, validTo string) { t.Helper() if _, err := s.db.ExecContext(context.Background(), `UPDATE memory_items SET expires_at = ?, valid_to = ? WHERE id = ?`, expiresAt, validTo, id); err != nil { t.Fatalf("set expiry (expires_at=%q valid_to=%q) on %s: %v", expiresAt, validTo, id, err) } } // assertReadSet 断言读路径返回的条目集合**恰好**是 want(按 id 比对), // 且 hidden 里的条目一条都没漏进来。按 id 而不是按正文断言:正文可能重复, // id 才是行的身份。 func assertReadSet(t *testing.T, name string, got, want, hidden []MemoryItem) { t.Helper() gotIDs := idsOfItems(got) wantIDs := idsOfItems(want) if !sameIDs(gotIDs, wantIDs) { t.Fatalf("%s = %v %v, want %v", name, gotIDs, contentsOf(got), wantIDs) } for _, h := range hidden { for _, id := range gotIDs { if id == h.ID { t.Errorf("%s 返回了已过期 / 已失效行 %q(时效闸失效)", name, h.Content) } } } } // TestStore_读路径_过滤已过期行 逐条走四条列表读路径与检索路径,证明「临时记忆到期」 // (expires_at ≤ now)与「事实已失效」(valid_to ≤ now)两种行在每一处都不可见, // 而空 expires_at(长期 / 日常记忆的默认值)与未来的 expires_at / valid_to 照常可见。 func TestStore_读路径_过滤已过期行(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) now := time.Now().UTC() // 可见组:长期(expires_at 空)、临时未到期(Add 直接给 ExpiresAt)、未失效(valid_to 未来)。 longTerm := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "长期-角色", Scope: ScopePersona, Kind: KindRelationship, }) tempAlive := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "临时-未到期-角色", Scope: ScopePersona, Kind: KindRelationship, ExpiresAt: now.Add(time.Hour), }) tempAliveUser := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "临时-未到期-用户", Scope: ScopeUser, Kind: KindWorldEvent, ExpiresAt: now.Add(time.Hour), }) validToFuture := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "未失效-项目", Scope: ScopeProject, Kind: KindWorldEvent, ProjectID: "proj.1", }) // 不可见组:临时已到期(写入侧落库的过去值)、valid_to 已过、项目域的临时已到期。 tempExpired := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship, ExpiresAt: now.Add(-time.Hour), }) validToPast := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent, }) tempExpiredProject := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "临时-已过期-项目", Scope: ScopeProject, Kind: KindWorldEvent, ProjectID: "proj.1", }) setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour))) setItemExpiryForTest(t, s, tempExpiredProject.ID, formatTime(now.Add(-time.Hour)), "") setItemExpiryForTest(t, s, validToFuture.ID, "", formatTime(now.Add(time.Hour))) ownVisible := []MemoryItem{longTerm, tempAlive, tempAliveUser, validToFuture} hidden := []MemoryItem{tempExpired, validToPast, tempExpiredProject} for _, tc := range []struct { name string read func() ([]MemoryItem, error) want []MemoryItem }{ { name: "ListByPersona", read: func() ([]MemoryItem, error) { return s.ListByPersona(ctx, "persona.a") }, want: ownVisible, }, { name: "List", read: func() ([]MemoryItem, error) { return s.List(ctx) }, want: ownVisible, }, { name: "ListScope/user", read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeUser, "persona.a") }, want: []MemoryItem{tempAliveUser}, }, { name: "ListScope/project", read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeProject, "persona.a") }, want: []MemoryItem{validToFuture}, }, { name: "ListProject", read: func() ([]MemoryItem, error) { return s.ListProject(ctx, "proj.1") }, want: []MemoryItem{validToFuture}, }, } { t.Run(tc.name, func(t *testing.T) { got, err := tc.read() if err != nil { t.Fatalf("%s: %v", tc.name, err) } assertReadSet(t, tc.name, got, tc.want, hidden) }) } // 检索路径:visibleCandidates 是候选集本身(Retrieve/Inject 与所有复用 Retriever 的 // 入口都只从它取候选),闸加在它组合调用的 ListByPersona + ListScope 上, // 因此这里必须一条过期行都没有。 r := NewRetriever(s) cands, err := r.visibleCandidates(ctx, "persona.a") if err != nil { t.Fatalf("visibleCandidates: %v", err) } assertReadSet(t, "visibleCandidates", cands, ownVisible, hidden) results, err := r.Retrieve(ctx, "persona.a", "", 20) if err != nil { t.Fatalf("Retrieve: %v", err) } if len(results) != len(ownVisible) { t.Fatalf("Retrieve = %d 条 (%v), want %d", len(results), idsOf(results), len(ownVisible)) } found := make([]MemoryItem, 0, len(results)) for _, res := range results { found = append(found, res.Item) } assertReadSet(t, "Retrieve", found, ownVisible, hidden) injected, err := r.Inject(ctx, "persona.a", "", 20) if err != nil { t.Fatalf("Inject: %v", err) } for _, h := range hidden { if strings.Contains(injected, h.Content) { t.Errorf("Inject 文本里出现了已过期 / 已失效行 %q(这就是「任何地方都调得到」的反面)", h.Content) } } for _, v := range ownVisible { if !strings.Contains(injected, v.Content) { t.Errorf("Inject 文本缺少在有效期内的 %q", v.Content) } } } // TestStore_Get_不过时效闸 是反向护栏:按 id 的 Get 刻意**不过** status 闸与时效闸。 // 撤销软合并要读得到被吸收的行(design/23 §8 的「无损可逆」),清算与人工核查要读得到 // 已过期 / 已失效的行(design/03 §11.2 第 3 条)。若有人「顺手」给 Get 也加闸,本条会红。 func TestStore_Get_不过时效闸(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) now := time.Now().UTC() tempExpired := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship, ExpiresAt: now.Add(-time.Hour), }) validToPast := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent, }) setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour))) for _, want := range []MemoryItem{tempExpired, validToPast} { got, err := s.Get(ctx, want.ID) if err != nil { t.Errorf("Get(%s) err = %v, want readable(撤销 / 审计的抓手)", want.ID, err) continue } if got.Content != want.Content { t.Errorf("Get(%s).Content = %q, want %q", want.ID, got.Content, want.Content) } } } // TestStore_Add_落库expires_at列 钉住写入侧:Add 是全仓唯一 INSERT 路径, // 时效列的**真生产者**只能是它 —— 若 Add 不写这一列,读侧时效闸就退化成恒真空壳 // (expires_at 永远为空 ⇒ 过期行永远可见)。零值必须落空串(= 不设过期), // 非零值必须走与读侧闸同一个 formatTime。 func TestStore_Add_落库expires_at列(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) expiry := time.Date(2026, 10, 20, 3, 20, 5, 830978900, time.UTC) withExpiry := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "临时-带时效", Scope: ScopePersona, Kind: KindRelationship, ExpiresAt: expiry, }) withoutExpiry := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "长期-无时效", Scope: ScopePersona, Kind: KindRelationship, }) for _, tc := range []struct { name string id string want string }{ {"非零 ExpiresAt", withExpiry.ID, formatTime(expiry)}, {"零值 ExpiresAt", withoutExpiry.ID, ""}, } { var raw string if err := s.db.QueryRowContext(ctx, `SELECT expires_at FROM memory_items WHERE id = ?`, tc.id).Scan(&raw); err != nil { t.Fatalf("%s: read raw expires_at: %v", tc.name, err) } if raw != tc.want { t.Errorf("%s: expires_at = %q, want %q", tc.name, raw, tc.want) } } } // TestTimeGate_比较口径与写入格式同源 是本仓历史事故的回归(DevDocs/开发习惯与约定.md §7): // 「SQLite 时间比较用错格式(带 T/Z 的串比 datetime('now'))会把『今天全部』当成『最近一小时』」。 // // 闸的处理方式写死为「两边都由 formatTime 产出同形文本,文本序即时间序」,本条把这件事钉三下: // 1. 闸的比较操作数与列值同源(同一个 formatTime,定宽 30 字节 UTC,可被 parseTime 解回); // 2. 同源文本的**文本序 == 时间序**(这是整个闸成立的前提); // 3. 跨格式比较会**系统性判错**(用 SQLite 字面量实证 'T' > ' ' 的陷阱), // 所以任何把闸改成 datetime('now') 之类的第二套口径的改动,都会先在这条上露馅。 func TestTimeGate_比较口径与写入格式同源(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) gateNow := timeGateNow() if len(gateNow) != storedTimeLen { t.Fatalf("timeGateNow() = %q (%d 字节), want %d 字节(必须与列值同宽)", gateNow, len(gateNow), storedTimeLen) } back, err := parseTime(gateNow) if err != nil { t.Fatalf("timeGateNow() 不能被 parseTime 解回: %v", err) } if reformatted := formatTime(back); reformatted != gateNow { t.Fatalf("timeGateNow() 与列值格式不同源:再格式化得 %q,不等于 %q", reformatted, gateNow) } // 文本序 == 时间序(两个都由 formatTime 产出的值)。 earlier := time.Date(2026, 10, 13, 11, 0, 0, 0, time.UTC) later := earlier.Add(time.Nanosecond) if !(formatTime(earlier) < formatTime(later)) { t.Fatalf("formatTime(%v) = %q 不早于 formatTime(%v) = %q —— 文本序已不等于时间序", earlier, formatTime(earlier), later, formatTime(later)) } // 同源比较:11:00 的列值小于 12:00 的当下 ⇒ 判为「已过期」。 var sameSource bool if err := s.db.QueryRowContext(ctx, `SELECT '2026-10-13T11:00:00.000000000Z' < ?`, formatTime(earlier.Add(time.Hour))). Scan(&sameSource); err != nil { t.Fatalf("same-source comparison: %v", err) } if !sameSource { t.Fatal("同源文本比较失效:11:00 的列值未被判为早于 12:00 的当下") } // 跨格式比较(历史事故的机制):SQLite 的 datetime('now') 形如 // "YYYY-MM-DD HH:MM:SS",第 11 个字节是空格(0x20),而列值第 11 个字节是 'T'(0x54)。 // 于是**同一 UTC 日**内,任何列值都「大于当下」—— 早一小时的过期行反而被判为未过期。 var crossFormat bool if err := s.db.QueryRowContext(ctx, `SELECT '2026-10-13T11:00:00.000000000Z' > datetime('2026-10-13 12:00:00')`). Scan(&crossFormat); err != nil { t.Fatalf("cross-format comparison: %v", err) } if !crossFormat { t.Fatalf("陷阱实证失效:跨格式比较不再系统性判错('T' vs ' ')," + "请重新核对 timegate.go 的口径与 DevDocs/开发习惯与约定.md §7") } } // TestTimeGate_闸不接受第二套时间口径 是结构性护栏:闸的 WHERE 片段里只允许出现 // 参数化比较(?),不得出现 SQLite 的时间函数 —— 一旦出现 datetime(...)/julianday(...) // 之类,就有第二套口径,写入格式或 SQLite 行为一变就再次静默分叉(历史事故正是如此)。 func TestTimeGate_闸不接受第二套时间口径(t *testing.T) { t.Parallel() for _, fn := range []string{"datetime(", "julianday(", "strftime(", "unixepoch(", "date("} { if strings.Contains(timeGateClause, fn) { t.Errorf("timeGateClause 含 SQLite 时间函数 %q:闸必须只做「同源定宽文本」的参数化比较(%s)", fn, timeGateClause) } } if got := strings.Count(timeGateClause, "?"); got != 2 { t.Errorf("timeGateClause 的占位符 = %d, want 2(expires_at 与 valid_to 各一个)", got) } } // TestExpiryText_零值落空串 钉住写入侧编码:零值 = 不设过期,必须落空串(列默认值), // 而不是 formatTime(零值)(那会写成 "0001-01-01T00:00:00.000000000Z",反而把长期记忆 // 变成「早已过期」—— 长期记忆会整体从读面消失)。 func TestExpiryText_零值落空串(t *testing.T) { t.Parallel() if got := expiryText(time.Time{}); got != "" { t.Errorf("expiryText(零值) = %q, want 空串(不设过期)", got) } at := time.Date(2026, 10, 20, 3, 20, 5, 0, time.UTC) got := expiryText(at) if want := formatTime(at); got != want { t.Errorf("expiryText(%v) = %q, want %q", at, got, want) } if len(got) != storedTimeLen { t.Errorf("expiryText(%v) 长度 = %d, want %d", at, len(got), storedTimeLen) } } // idsOfItems 取条目 id,仅用于失败信息与集合比对。 func idsOfItems(items []MemoryItem) []string { out := make([]string, 0, len(items)) for _, it := range items { out = append(out, it.ID) } sort.Strings(out) return out } // sameIDs 报告两个**已排序**的 id 切片是否逐项相等。 func sameIDs(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }