package memory import ( "context" "slices" "testing" ) // 本文件钉住**迁移 0028(`memory_sensitive`)与 Store 自举 DDL 的同形、可回滚、 // 以及「迁移里的白名单回填」与 Go 侧回填判据等价**。手法照 retention_migration_test.go // 对 0026、temporal_migration_test.go 对 0027 的那一套(同一批 helpers)。 // // 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份 // 真源:迁移(权威)与 NewStore 的幂等前滚(sensitive_schema.go)。少一列就是 // 「新库有、老库无」的漂移,因此三条判据都必须有: // // 1. TestMigration0028AddsColumnAndBackfills —— 迁移落在**已有数据**上:行不丢、 // 列补上、白名单命中的历史行被标成 sensitive=1、非白名单行保持 0; // 2. TestMigration0028DownDropsColumnOnly —— down 只撤结构(行数与其它数据不变); // 3. TestMigration0028ShapeMatchesStoreBootstrap —— 两份 DDL 的列集合与列形状同形, // 且自举侧尾序是 `… temporal, sensitive`。 // // 第 1 条同时是**白名单等价性**的证据:迁移里那份短语清单与 Go 的 privateFacts // 若不一致,`BackfillSensitive` 在迁移之后重跑会标出额外的行(Marked > 0)—— // 那条断言把「两份实现悄悄分叉」变成一次红灯。 // sensitiveMigrationID 是 0028 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。 const sensitiveMigrationID = "0028_memory_sensitive" // TestMigration0028AddsColumnAndBackfills 造出「0028 之前的既有部署」再升级: // 历史行一行不丢、白名单命中的被标、没命中的不被标。 func TestMigration0028AddsColumnAndBackfills(t *testing.T) { t.Parallel() ctx := context.Background() d := openRawMemoryDB(t) migrateUp(t, d) if !hasMemoryColumn(t, d, sensitiveColumn) { t.Fatalf("迁移后 memory_items 应带 %s 列", sensitiveColumn) } // 回到 0028 之前,按旧形态写四行(花溪 / 叶酱 / 早睡 / 习惯)。 migrateDownToBefore(t, d, sensitiveMigrationID) if hasMemoryColumn(t, d, sensitiveColumn) { t.Fatalf("0028 回滚后 %s 应已消失", sensitiveColumn) } seedLegacySensitiveRow(t, d, "legacy-1", string(ScopeUser), "用户居住在花溪(贵阳花溪区)") seedLegacySensitiveRow(t, d, "legacy-2", string(ScopePersona), "主人希望被称作「叶酱」") seedLegacySensitiveRow(t, d, "legacy-3", string(ScopeProject), "主人常熬夜,助手提醒早睡") seedLegacySensitiveRow(t, d, "legacy-4", string(ScopeUser), "用户喜欢冰美式") // 升级:0028 落在**已有数据**上(建列 + 回填)。 migrateUp(t, d) if !hasMemoryColumn(t, d, sensitiveColumn) { t.Fatalf("迁移后 memory_items 应带 %s 列", sensitiveColumn) } var rows int if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil { t.Fatalf("count rows: %v", err) } if rows != 4 { t.Fatalf("迁移后行数 = %d, want 4(既有行一行都不能丢)", rows) } for _, id := range []string{"legacy-1", "legacy-2", "legacy-3"} { if got := readSensitiveCell(t, d, id); got != 1 { t.Fatalf("%s 回填后 sensitive = %d, want 1(白名单命中必须被标)", id, got) } } if got := readSensitiveCell(t, d, "legacy-4"); got != 0 { t.Fatalf("legacy-4(习惯类)被标成敏感 = %d —— 迁移把「用户习惯可查」推翻了", got) } // 白名单等价性:迁移已经标完,Go 侧回填不该再标出一行。 report, err := BackfillSensitive(ctx, d, false) if err != nil { t.Fatalf("BackfillSensitive(迁移之后): %v", err) } if report.Marked != 0 { t.Fatalf("迁移之后 Go 侧回填又标了 %d 行 —— 迁移里的短语清单与 privateFacts 已经分叉", report.Marked) } if report.Matched != 3 { t.Fatalf("Matched = %d, want 3(两条实现的判据必须一致)", report.Matched) } } // TestMigration0028DownDropsColumnOnly 钉住 down 的唯一语义:只撤结构。 func TestMigration0028DownDropsColumnOnly(t *testing.T) { t.Parallel() ctx := context.Background() d := openRawMemoryDB(t) migrateUp(t, d) seedLegacySensitiveRow(t, d, "keep-1", string(ScopeUser), "用户居住在花溪") if _, err := d.ExecContext(ctx, `UPDATE memory_items SET sensitive = 1 WHERE id = 'keep-1'`); err != nil { t.Fatalf("mark row: %v", err) } migrateDownToBefore(t, d, sensitiveMigrationID) if hasMemoryColumn(t, d, sensitiveColumn) { t.Fatalf("回滚后 %s 应已消失", sensitiveColumn) } var rows int if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil { t.Fatalf("count rows after down: %v", err) } if rows != 1 { t.Fatalf("回滚后行数 = %d, want 1(down 只撤结构,不删行)", rows) } var content, scope string if err := d.QueryRowContext(ctx, `SELECT content, scope FROM memory_items WHERE id = 'keep-1'`).Scan(&content, &scope); err != nil { t.Fatalf("read row after down: %v", err) } if content != "用户居住在花溪" || scope != string(ScopeUser) { t.Fatalf("down 改写了行数据 = (%q, %q) —— 回滚只撤结构", content, scope) } // 再 up:列以默认 0 重建,回填重新按白名单标上。 migrateUp(t, d) if !hasMemoryColumn(t, d, sensitiveColumn) { t.Fatalf("再次迁移后应重建 %s 列", sensitiveColumn) } if got := readSensitiveCell(t, d, "keep-1"); got != 1 { t.Fatalf("再 up 之后 sensitive = %d, want 1(回填随迁移重跑)", got) } } // TestMigration0028ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移路径与自举路径 // 建出的 memory_items 列集合必须一致,且 `sensitive` 在两边的形状逐字相同 // (INTEGER NOT NULL DEFAULT 0)。 func TestMigration0028ShapeMatchesStoreBootstrap(t *testing.T) { t.Parallel() ctx := context.Background() migrated := openRawMemoryDB(t) migrateUp(t, migrated) if _, err := NewStore(ctx, migrated, testLogger()); err != nil { t.Fatalf("NewStore(migrated): %v", err) } bootstrapped := openRawMemoryDB(t) if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil { t.Fatalf("NewStore: %v", err) } gotCols := sortedColumns(tableColumns(t, migrated, "memory_items")) wantCols := sortedColumns(tableColumns(t, bootstrapped, "memory_items")) if !slices.Equal(gotCols, wantCols) { t.Fatalf("迁移与自举的 memory_items 列集合不一致(两份 DDL 必须同形):\n迁移=%v\n自举=%v", gotCols, wantCols) } for name, cols := range map[string][]string{"迁移": gotCols, "自举": wantCols} { if !slices.Contains(cols, sensitiveColumn+"|INTEGER|1|0|0") { t.Fatalf("%s 侧的 %s 列形状不是 INTEGER NOT NULL DEFAULT 0:%v", name, sensitiveColumn, cols) } } } // TestMigration0028ThenStoreBootstrapIsARegularPath 覆盖真实启动顺序:迁移先建列 // (含回填),随后 Store 自举走到 "duplicate column name" —— 预期路径必须无害, // 且写入路径可用(敏感位随同一笔 INSERT 落下)。 func TestMigration0028ThenStoreBootstrapIsARegularPath(t *testing.T) { t.Parallel() ctx := context.Background() d := openRawMemoryDB(t) migrateUp(t, d) store, err := NewStore(ctx, d, testLogger()) if err != nil { t.Fatalf("NewStore(列已由迁移建好): %v", err) } after := tableColumns(t, d, "memory_items") if _, err := NewStore(ctx, d, testLogger()); err != nil { t.Fatalf("NewStore(第二次): %v", err) } if got := tableColumns(t, d, "memory_items"); !slices.Equal(got, after) { t.Fatalf("第二次自举改变了表结构:\n got=%v\nwant=%v", got, after) } item := habitItem("主人现居贵阳花溪区", tidyTestTime(t), 0.9) got, err := store.Add(ctx, item) if err != nil { t.Fatalf("Add: %v", err) } if cell := readSensitiveCell(t, store.db, got.ID); cell != 1 { t.Fatalf("迁移建列后经 Store 落库的 sensitive = %d, want 1(写入侧兜底没跑)", cell) } }