package repo import ( "context" "testing" "github.com/agentmail/gateway/internal/db" "github.com/google/uuid" ) /* 工作区维度:**同一个 Agent 名字在不同工作区各有独立收件箱。** # 用户报的缺陷(12 天前就提过,这轮才真修) 「不同 session 的 agent 都可以看到全部邮件」—— `552fbc7` 修了 **session_id** 那一维, 但**工作区**那一维从没做。生产实测(2026-09-26):在 `mc` 工作区干活的 pi 读收件箱 拿到 200 封,其中 191 封属于 `/home/program/agentmail` —— 它照着那些信里的断言去改 agentmail 的代码,把手上 mc 的活丢在一边。用户当场问「你怎么干着干着修 agentmail 去了?」 三维地址是 `name@path.session`。**path 位本来就该参与寻址**,收件箱侧此前完全没用它。 # 判据要两侧都验 只验「带工作区 A 时看不到 B 的信」不够 —— 把列表直接返回空也能过。 所以同时验反向对照:「不带工作区时两条都看得到」(人类侧语义)。 两侧一起才证明是**收窄**而不是**清空**。 */ func seedMailInWorkspace(t *testing.T, workspace, recipient string) uuid.UUID { t.Helper() ctx := context.Background() sid, err := CreateSession(ctx, nil, "human", "ws-"+workspace, workspace) if err != nil { t.Fatal(err) } var id uuid.UUID if err := db.DB.QueryRowContext(ctx, `INSERT INTO mails (session_id, from_name, to_name, subject, body, to_workspace) VALUES ($1, 'sender', $2, 's', 'b', $3) RETURNING mail_id`, sid, recipient, workspace).Scan(&id); err != nil { t.Fatal(err) } return id } func TestInboxListIsScopedByWorkspace(t *testing.T) { setupTestDB(t) ctx := context.Background() const wsA = "/home/program/agentmail" const wsB = "/home/newqqagent/plugindev/mc" a := seedMailInWorkspace(t, wsA, "pi") b := seedMailInWorkspace(t, wsB, "pi") // ① 带工作区 A ⇒ 只有 A 那封 onlyA, err := ListInbox(ctx, "pi", "all", wsA, 50) if err != nil { t.Fatal(err) } if len(onlyA) != 1 || onlyA[0].ID != a { t.Fatalf("工作区 A 应收窄到 1 封(A 那封),实际 %d 封", len(onlyA)) } // ② 带工作区 B ⇒ 只有 B 那封(**反向对照**:证明不是"总是返回第一封") onlyB, err := ListInbox(ctx, "pi", "all", wsB, 50) if err != nil { t.Fatal(err) } if len(onlyB) != 1 || onlyB[0].ID != b { t.Fatalf("工作区 B 应收窄到 1 封(B 那封),实际 %d 封", len(onlyB)) } // ③ 不带工作区(人类侧语义)⇒ 两封都看得到 // ★ 这一条是"收窄 vs 清空"的分水岭:少了它,把 ① 实现成 return nil 也能过。 all, err := ListInbox(ctx, "pi", "all", "", 50) if err != nil { t.Fatal(err) } if len(all) != 2 { t.Fatalf("不带工作区应看到 2 封(人类跨工作区),实际 %d 封", len(all)) } // ④ 未读数同样按工作区算 —— 它是桥的补投判据 pending_mails。 // 口径不一致时,桥会认为"还有信没投"并反复重放。 nA, err := CountUnread(ctx, "pi", wsA) if err != nil { t.Fatal(err) } nB, err := CountUnread(ctx, "pi", wsB) if err != nil { t.Fatal(err) } nAll, err := CountUnread(ctx, "pi", "") if err != nil { t.Fatal(err) } if nA != 1 || nB != 1 || nAll != 2 { t.Fatalf("未读数应为 A=1 B=1 全部=2,实际 A=%d B=%d 全部=%d", nA, nB, nAll) } } // 「全部标已读」也必须按工作区收窄 —— 否则标掉别的工作区的未读 = 静默丢信 // (session_scope_test.go 记过这个形状)。 func TestMarkAllReadIsScopedByWorkspace(t *testing.T) { setupTestDB(t) ctx := context.Background() const wsA = "/home/program/agentmail" const wsB = "/home/newqqagent/plugindev/mc" seedMailInWorkspace(t, wsA, "pi") seedMailInWorkspace(t, wsB, "pi") n, err := MarkAllInboxReadForSession(ctx, "pi", wsA, uuid.Nil) if err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("标已读应只影响工作区 A 的 1 封,实际 %d 封", n) } // A 的标掉了 if left, _ := CountUnread(ctx, "pi", wsA); left != 0 { t.Fatalf("工作区 A 应已清空,实际剩 %d 封未读", left) } // B 的**必须还在**(这是本判据的重点) if left, _ := CountUnread(ctx, "pi", wsB); left != 1 { t.Fatalf("工作区 B 的未读被误标了 —— 这是静默丢信(应剩 1 封,实际 %d)", left) } // 反向对照:不带工作区时全标掉(人类侧) if _, err := MarkAllInboxReadForSession(ctx, "pi", "", uuid.Nil); err != nil { t.Fatal(err) } if left, _ := CountUnread(ctx, "pi", ""); left != 0 { t.Fatalf("不带工作区应全部标掉,实际剩 %d 封", left) } } // workspace 的形状校验:非空时必须是绝对路径。 // 相对路径在服务端无法解释(不同调用方 cwd 不同 ⇒ 拼出来必然对不上), // 而"对不上"的表现是**静默返回空收件箱** —— 那比报错难查得多。 func TestWorkspaceMustBeAbsolute(t *testing.T) { setupTestDB(t) ctx := context.Background() seedMailInWorkspace(t, "/home/program/agentmail", "pi") for _, bad := range []string{"relative/path", "./here", "~/home"} { if _, err := ListInbox(ctx, "pi", "all", bad, 50); err == nil { t.Fatalf("ListInbox 对相对路径 %q 必须报错,实际通过(会静默返回空收件箱)", bad) } if _, err := CountUnread(ctx, "pi", bad); err == nil { t.Fatalf("CountUnread 对相对路径 %q 必须报错", bad) } } }