Files
MailUI4Agents/server/internal/handler/session_permission_broadcast_test.go
JianFeeeee a8d6811cd7 style(server): gofmt 注释规范 + init.sql 补 parent_session_id + 新增 ratelimit 判据
## 主体是 gofmt

`server/` 下 20 个文件的改动里,**142/147 行是注释与缩进** ——
Go 1.19+ 的 gofmt 把块注释里的 `//   x` 规范成 `//\tx`。
功能零变化。

## 一处实质:init.sql 补 parent_session_id

`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS parent_session_id
 TEXT REFERENCES sessions(session_id)`

远端已在 `db/migrate.go` 的 `sqliteAddColumns` 结构迁移通道里加了同一列;
init.sql 是**全新库**的建库脚本,缺这列会让新库少一个字段。

- `IF NOT EXISTS` ⇒ 与结构迁移通道幂等,两者并存无害
- 默认空 = 根节点;**不据历史数据猜父子**(猜错会让线索拓扑失真,比没有更糟)

## 新增 handler/ratelimit_test.go(162 行)

速率限制的判据。此前这一块没有独立判据。

## 验证(本机实跑,非推断)

    go build ./...                              通过
    go vet ./internal/{handler,repo,db,sse}/   通过
    go test -count=1(handler/repo/db/sse/blob)
      handler  41.3s  ok
      repo    101.9s  ok
      db       1.1s  ok
      sse      0.01s ok
      blob     0.08s ok

`-count=1` 是刻意的:第一遍跑出来全是 `(cached)`,那种绿灯不算数。
2026-10-05 09:26:59 +08:00

107 lines
4.4 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 handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/sse"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
/*
* 判据:**值没变也要广播**(pi 2026-09-14 §3:这是一个"承重的没有")。
*
* # 它承载什么
*
* "已经降级的会话怎么恢复"这个问题,我在回信里给的答案依赖一条**否定事实**:
* `UpdateSessionPermission` **没有** "值没变就提前返回" —— 所以人在 WebUI 里把同一个档位
* **再选一次也有效**,而且在**旧版桥仍在生产上跑**的时候就能生效(不需要等部署)。
* 这是我唯一给出的"今天就能用"的恢复手段。
*
* 一条否定事实如果没人替下一个人验一遍,就会以"我以为它有那个 early return"的形式烂掉 ——
* 而这条一旦被加上提前返回,恢复路径就**静默**消失了:用户点了一次、界面没变化、
* 插件也没收到新档位,看起来像"点了没用"。
*
* # 为什么是行为判据而不是读源码
*
* 判源码形状只能证明"现在没有那个 if",改天有人把判断挪到 repo 层、挪到 middleware、
* 或者写成"值相等就不调 Broadcast 但调别的",形状判据全都漏。
* 这里直接**订阅 SSE 再点两次同一个档位**,数收到的 `session_update` 帧:
* 两次都必须有。与"探针要真跑一次"同一个道理。
*/
func TestPermissionUpdateBroadcastsEvenWhenValueUnchanged(t *testing.T) {
dir := t.TempDir()
if err := db.Connect(context.Background(), "sqlite://"+filepath.Join(dir, "t.db")); err != nil {
t.Fatalf("connect: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() { db.Close() })
ctx := context.Background()
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO users (username, password_hash, role) VALUES ('alice', 'x', 'user')`); err != nil {
t.Fatalf("建用户: %v", err)
}
var userID uuid.UUID
if err := db.DB.QueryRowContext(ctx, `SELECT user_id FROM users WHERE username = 'alice'`).Scan(&userID); err != nil {
t.Fatalf("读用户 id: %v", err)
}
var sessionID uuid.UUID
if err := db.DB.QueryRowContext(ctx,
`INSERT INTO sessions (from_agent, subject, workspace, owner_user_id)
VALUES ('sender', 's', '', $1) RETURNING session_id`, userID).Scan(&sessionID); err != nil {
t.Fatalf("建会话: %v", err)
}
// 订阅 SSE:真的挂一个客户端上去,然后数帧
rec := httptest.NewRecorder()
sub := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil)
sub = sub.WithContext(context.WithValue(sub.Context(),
middleware.UserKey, &models.User{ID: userID, Username: "alice"}))
sse.Default.AddClient(rec, sub, "", "alice")
t.Cleanup(func() { sse.Default.RemoveClient("") })
call := func(mode string) {
body, _ := json.Marshal(map[string]string{"permission_mode": mode})
r := httptest.NewRequest(http.MethodPut, "/api/v1/sessions/"+sessionID.String()+"/permission",
strings.NewReader(string(body)))
r = r.WithContext(context.WithValue(r.Context(),
middleware.UserKey, &models.User{ID: userID, Username: "alice"}))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", sessionID.String())
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
UpdateSessionPermission(w, r)
if w.Code != http.StatusOK {
t.Fatalf("改档位返回 %d:%s", w.Code, w.Body.String())
}
}
count := func() int { return strings.Count(rec.Body.String(), "session_update") }
call("workspace") // 第一次:真变化
afterFirst := count()
if afterFirst == 0 {
t.Fatalf("第一次改档位没收到 session_update(判据自己没接上:先看 SSE 客户端有没有挂上)")
}
call("workspace") // 第二次:**值没变** —— 恢复路径就靠这一下
afterSecond := count()
if afterSecond <= afterFirst {
t.Fatalf("同一个档位再选一次**没有**再广播(%d → %d)。\n"+
"这条不是洁癖:人在 WebUI 里重选同值是**已降级会话**在旧版桥仍在线时唯一的恢复手段,\n"+
"加上「值没变就不广播」会让它静默失效 —— 用户点了一次、界面没变、插件没收到,看起来像没反应。",
afterFirst, afterSecond)
}
}