Files
MailUI4Agents/server/internal/repo/appearance_semantics_test.go
JianFeeeee 63649031d8 后端: 销掉一份漂移的欠账副本 + 钉住 user_appearance.user_id 的语义陷阱
## 一、`user_appearance.user_id` 存的是**用户名**(不是 UUID)—— 陷阱显式化

审计时实测到的差异:

| 表 | `user_id` 存的是 |
|---|---|
| `user_keys` | **UUID**(`809967c5-…`) |
| `user_sessions` | **UUID** |
| `user_appearance` | **用户名**(`jianf`)← 与兄弟表不同 |

**功能上自洽**(本包读写都用 username,handler 一律传 `user.Username`),
所以**不是活跃 bug**。但它是个**不报错的陷阱**:按兄弟表的习惯写

    LEFT JOIN user_appearance a ON u.user_id = a.user_id

会**静默匹配到 0 行**。我自己审计时就先踩了一次 —— 查出来全是空,
差点当成"这些账号从没设置过外观"(实际 jianf 有记录)。

**没有改列名**:表里有生产数据(壁纸本体在 blob 里、由 `image_sha256` 引用),
改列要迁移 + 回滚预案,收益只是"名字更好看"。选择**把语义钉住**:
文件头写清(**指名兄弟表**当锚、说清**失败长什么样**)+ 新判据
`appearance_semantics_test.go` 双向校验(repo 层不得混入 UUID 语义、
handler 层不得出现 `user.UserID`)。

★ 判据从源码读而不是运行时测:这里要钉的是**约定**,
而约定的存在形式就是注释与命名 —— 行为测试证明不了"下一个人不会踩"。

## 二、销掉一份**已经漂移**的欠账副本

`debt_registry_test.go` 里有一份**手写副本** `debts` map(给 `debtSummary()`
打印用),而**没有任何判据校验它与权威 `docs/DEBTS.json` 一致**。
实测漂移得很厉害:

- 副本 3 条 vs 权威 **17 条**;
- 里面还留着 **`gesture-semantics`** —— 那一笔已于 2026-09-19 还清并销账;
- `static-criteria` 的余额还是旧值 **7**(权威已是 5)。

后果很具体:**`TestMain` 打印的余额是错的**,而余额的全部意义就是"能看全"。

处理:**删掉副本**,让 `debtSummary()` 直接读权威 JSON(同一个事实不留两份),
并加判据 `TestDebtSummaryReadsAuthoritativeLedger` 双向钉住:
① 不许再引入手写副本;② 打出来的数字必须与 JSON 一致,
且**每一笔余额>0 的都要出现在打印里**(漏掉的欠账等于不存在)。

同时修了 `TestDebtLedgerMatchesMeasurement` 里一条**硬编码欠账清单**的断言
(它点名要求 `gesture-semantics` 必须存在 —— 还清了反而红)。
改成断言**形状**(跨端净值 + 本包可实测的那笔必须同处登记),不点名具体笔数。
★ 教训:硬编码欠账清单不可维护,漏改的后果是"还清了反而红"。

## 三、写这三条判据时连踩两次**自匹配**

`strings.Contains(src, "var debts = map[string]debt{")` —— 那串字**本身**
出现在断言的 `t.Fatal` 消息里(以及我留的说明注释里)⇒ 恒红。
(`run-all.mjs` 的自检锚点也栽过一次,那里改用 `lastIndexOf`。)
修法:锚点**拼出来**(`"var " + "debts" + " = map..."`)+ 注释措辞避开那个声明。

## 四、验证

`go test ./...` → **13 个包全过**。
新增:`appearance_semantics_test.go`(4 条断言)、
`TestDebtSummaryReadsAuthoritativeLedger`(2 条)。
2026-09-19 16:27:09 +08:00

87 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"os"
"strings"
"testing"
)
/*
* `user_appearance.user_id` 存的是**用户名**(不是 UUID)—— 这件事必须被钉住。
*
* # 为什么值得一条判据
*
* 它**不是**活跃 bug(本包读写都用 username,handler 也一律传 `user.Username`),
* 但它是一个**不报错的陷阱**:兄弟表的同名列存的是 UUID
* (`user_keys` / `user_sessions` 实测都是 `809967c5-…`),
* 而任何人按那个习惯写 JOIN 会**静默匹配到 0 行**。
*
* 我自己在 2026-09-19 审计时就先踩了一次:用
* `LEFT JOIN user_appearance a ON u.user_id = a.user_id` 查出全是空,
* 差点当成"这些账号从没设置过外观"(而实际上 jianf 有记录)。
*
* # 这条判据钉什么
*
* ① **值来源**:本包所有涉及该表的查询都必须用 `user.Username`/username 语义
* (具体表现:函数的参数名与调用契约里出现 username 字样,而不是 UserID);
* ② **陷阱被写明**:文件头要有一段解释"这一列与兄弟表不同、JOIN 会静默失配",
* 否则下一个人只会重新踩一遍;
* ③ **README 级的可见性**:那条注释必须提到一个**具体的兄弟表名**
* (拿它当"这里是异常的那一个"的锚)—— 空泛的"注意类型"没人会读。
*
* ★ 判据从 `*_test.go` 读源码(不是运行时行为):这里要钉的是**约定**,
* 而约定的存在形式就是注释与命名。行为测试证明不了"下一个人不会踩"。
*/
func TestUserAppearanceStoresUsernameNotUUID(t *testing.T) {
src, err := os.ReadFile("appearance.go")
if err != nil {
t.Fatalf("读不到 appearance.go:%v", err)
}
text := string(src)
/* ① 陷阱必须被写明,且要指名一个兄弟表(拿它当"异常的是这张"的锚) */
if !strings.Contains(text, "存的是") || !strings.Contains(text, "用户名") {
t.Fatal("★ appearance.go 的头部必须写明 user_appearance.user_id 存的是**用户名** —— " +
"它与兄弟表不同,不写清楚下一个人只会重新踩(我踩过一次)")
}
siblings := []string{"user_keys", "user_sessions"}
found := false
for _, s := range siblings {
if strings.Contains(text, s) {
found = true
break
}
}
if !found {
t.Fatal("★ 那段说明必须**指名一个兄弟表**(user_keys / user_sessions)—— " +
"'注意列名'这种空泛提醒没人会读;指名才让人知道去比对什么")
}
if !strings.Contains(text, "JOIN") {
t.Fatal("★ 必须说清**失败长什么样**(JOIN 会静默匹配到 0 行)—— " +
"只说'语义不同'不能让人预见到后果")
}
/* ② 本包对该表的所有查询都要走同一个语义(不得混入 UUID 语义的参数名) */
if strings.Contains(text, "userUUID") || strings.Contains(text, "user_id UUID") {
t.Fatal("★ 出现 UUID 语义的标识符 —— 该表存的是用户名,两种语义混用必然出错")
}
/*
* ③ handler 侧也必须一致(跨文件的一致性是这条约定的另一半):
* 若哪天有人在 handler 里改成传 UUID,功能会**静默失效**
* (读不到记录 ⇒ 一律返回默认外观,且完全不报错)。
*/
hsrc, err := os.ReadFile("../handler/appearance.go")
if err != nil {
t.Fatalf("读不到 handler/appearance.go:%v", err)
}
htext := string(hsrc)
if strings.Contains(htext, "user.UserID") {
t.Fatal("★ handler 里不得出现 `user.UserID` —— " +
"本表按**用户名**存取;传 UUID 会静默读不到记录(表现:外观永远回到默认值,且没有报错)")
}
if !strings.Contains(htext, "user.Username") {
t.Fatal("★ handler 应显式使用 `user.Username`(这是那条约定的落点)")
}
}