后端: - 迁移 0007:chat_messages 加 adapter_id 列 + 索引 - history.Message 加 AdapterID;persistToMain/persistIsolated 传适配器名(a.Name()) - channelbridge: 4 个持久化点 + registerSilenced 传 a;light.go registerSilenced 签名加 a - Store 新增 ListAdapterLog(?adapter=, 不排除主会话)/ListAllLog(adapter_id!='', 总览);ListPlatformLog 保留 - 新端点 GET /api/v1/admin/channels/messages:?adapter= 配置级 / ?platform= 平台 / 无参=总览,historyMessage 加 adapterId - 测试:ListAdapterLog/ListAllLog 过滤用例 + 端点粒度用例 + 桥接持久化断言 前端:渠道管理每配置(账号)「消息日志」按钮 + 页顶「适配器总览日志」入口 + ChannelLogPanel 弹窗(总览/配置级),Miuix 风格,build 绿。
335 lines
13 KiB
Go
335 lines
13 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
|
)
|
|
|
|
// testLogger returns a logger that writes nowhere, keeping test output clean.
|
|
func testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// newHistoryEnv builds a Server wired with a real in-memory history store, returning the
|
|
// httptest server, the JWT service and the underlying history store.
|
|
func newHistoryEnv(t *testing.T) (*Server, *httptest.Server, *auth.JWTService, *history.Store) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
conn, err := db.Open(ctx, ":memory:", testLogger())
|
|
if err != nil {
|
|
t.Fatalf("open memory db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
st, err := history.NewStore(ctx, conn, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("new history store: %v", err)
|
|
}
|
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: st})
|
|
return srv, ts, jwt, st
|
|
}
|
|
|
|
// decodeDataSlice decodes the {data:[...]} envelope into the given slice target.
|
|
func decodeDataSlice(t *testing.T, body []byte, out any) {
|
|
t.Helper()
|
|
if err := json.Unmarshal(decodeBody(t, body)["data"], out); err != nil {
|
|
t.Fatalf("decode data: %v (body=%s)", err, body)
|
|
}
|
|
}
|
|
|
|
func TestHistoryCreateAndListSessions(t *testing.T) {
|
|
t.Parallel()
|
|
_, ts, jwt, _ := newHistoryEnv(t)
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
// Fresh store always lists the main session.
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var initial []historySessionSummary
|
|
decodeDataSlice(t, body, &initial)
|
|
if len(initial) != 1 || initial[0].ID != history.MainSessionID || initial[0].Type != history.SessionMain {
|
|
t.Fatalf("initial sessions = %+v, want [main web]", initial)
|
|
}
|
|
|
|
// Create a sub session.
|
|
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var created struct {
|
|
SessionID string `json:"sessionId"`
|
|
}
|
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &created); err != nil {
|
|
t.Fatalf("decode create data: %v (body=%s)", err, body)
|
|
}
|
|
if !strings.HasPrefix(created.SessionID, history.MainSessionID+":") {
|
|
t.Fatalf("sessionId = %q, want prefix web:", created.SessionID)
|
|
}
|
|
|
|
// Now main first, then the sub.
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var sessions []historySessionSummary
|
|
decodeDataSlice(t, body, &sessions)
|
|
if len(sessions) != 2 || sessions[0].ID != history.MainSessionID || sessions[0].Type != history.SessionMain {
|
|
t.Fatalf("sessions[0] = %+v, want main web first", sessions[0])
|
|
}
|
|
if sessions[1].ID != created.SessionID || sessions[1].Type != history.SessionSub {
|
|
t.Fatalf("sessions[1] = %+v, want sub %s", sessions[1], created.SessionID)
|
|
}
|
|
}
|
|
|
|
func TestHistoryListMessagesPagination(t *testing.T) {
|
|
t.Parallel()
|
|
_, ts, jwt, st := newHistoryEnv(t)
|
|
ctx := context.Background()
|
|
// Seed the main session with messages at ascending ms timestamps.
|
|
for _, ts := range []int64{1000, 2000, 3000, 4000} {
|
|
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "x", CreatedAt: ts}); err != nil {
|
|
t.Fatalf("seed %d: %v", ts, err)
|
|
}
|
|
}
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
// Newest page of 2.
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=2", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("first page status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var page []historyMessage
|
|
decodeDataSlice(t, body, &page)
|
|
if len(page) != 2 || page[0].CreatedAt != 4000 || page[1].CreatedAt != 3000 {
|
|
t.Fatalf("first page = %+v, want [4000 3000]", page)
|
|
}
|
|
|
|
// Cursor before the page's oldest (3000) → older page [2000 1000].
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?before=3000&limit=2", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("older page status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var older []historyMessage
|
|
decodeDataSlice(t, body, &older)
|
|
if len(older) != 2 || older[0].CreatedAt != 2000 || older[1].CreatedAt != 1000 {
|
|
t.Fatalf("older page = %+v, want [2000 1000]", older)
|
|
}
|
|
|
|
// Invalid limit/before → 400.
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=abc", nil, tok)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("invalid limit status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
func TestHistoryListMessagesPlatformFilter(t *testing.T) {
|
|
t.Parallel()
|
|
_, ts, jwt, st := newHistoryEnv(t)
|
|
ctx := context.Background()
|
|
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "web", Platform: history.PlatformWeb, CreatedAt: 1000}); err != nil {
|
|
t.Fatalf("seed web: %v", err)
|
|
}
|
|
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "tg", Platform: "telegram", CreatedAt: 2000}); err != nil {
|
|
t.Fatalf("seed tg: %v", err)
|
|
}
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
// No platform → the full main session (both lanes).
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=10", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("all status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var all []historyMessage
|
|
decodeDataSlice(t, body, &all)
|
|
if len(all) != 2 {
|
|
t.Fatalf("all = %d, want 2", len(all))
|
|
}
|
|
|
|
// ?platform=telegram → only the telegram lane, and the DTO carries platform.
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=10&platform=telegram", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("platform status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var tg []historyMessage
|
|
decodeDataSlice(t, body, &tg)
|
|
if len(tg) != 1 || tg[0].Content != "tg" || tg[0].Platform != "telegram" {
|
|
t.Fatalf("telegram page = %+v, want one platform=telegram message", tg)
|
|
}
|
|
}
|
|
|
|
// handleChannelMessagesGranularity exercises the admin adapter log endpoint at each
|
|
// granularity: a single adapter config (?adapter=), a whole platform (?platform=),
|
|
// and the full IM adapter overview (neither). It asserts adapterId is carried on the
|
|
// DTO so the panel can label the source adapter config.
|
|
func TestHistoryChannelMessagesGranularity(t *testing.T) {
|
|
t.Parallel()
|
|
_, ts, jwt, st := newHistoryEnv(t)
|
|
ctx := context.Background()
|
|
for _, m := range []history.Message{
|
|
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "kook-owner", Platform: "kook", AdapterID: "kook:3bef", CreatedAt: 1000},
|
|
{SessionID: "sess-g", Role: history.RoleUser, Content: "kook-group", Platform: "kook", AdapterID: "kook:3bef", SenderName: "张三", CreatedAt: 2000},
|
|
{SessionID: "sess-t", Role: history.RoleUser, Content: "tg-group", Platform: "telegram", AdapterID: "telegram:conf0", CreatedAt: 3000},
|
|
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "web-msg", Platform: history.PlatformWeb, CreatedAt: 4000},
|
|
} {
|
|
if err := st.Append(ctx, m); err != nil {
|
|
t.Fatalf("seed %q: %v", m.Content, err)
|
|
}
|
|
}
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
// ?adapter=kook:3bef → only that adapter config, both the owner mirror and the
|
|
// group message, newest first, and the DTO carries adapterId.
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?adapter=kook:3bef", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("adapter status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var byAdapter []historyMessage
|
|
decodeDataSlice(t, body, &byAdapter)
|
|
if len(byAdapter) != 2 {
|
|
t.Fatalf("adapter page = %d, want 2 (%+v)", len(byAdapter), byAdapter)
|
|
}
|
|
if byAdapter[0].Content != "kook-group" || byAdapter[0].AdapterID != "kook:3bef" {
|
|
t.Fatalf("adapter[0] = %+v, want kook-group with adapterId", byAdapter[0])
|
|
}
|
|
if byAdapter[1].Content != "kook-owner" || byAdapter[1].SessionID != history.MainSessionID {
|
|
t.Fatalf("adapter[1] = %+v, want kook-owner on main session", byAdapter[1])
|
|
}
|
|
|
|
// ?platform=kook → the platform lane across isolated sessions (owner-private
|
|
// mirror excluded, matching handleChannelPlatformLog semantics).
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?platform=kook", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("platform status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var byPlatform []historyMessage
|
|
decodeDataSlice(t, body, &byPlatform)
|
|
if len(byPlatform) != 1 || byPlatform[0].Content != "kook-group" {
|
|
t.Fatalf("platform page = %+v, want [kook-group]", byPlatform)
|
|
}
|
|
|
|
// No filter → the full IM adapter overview (adapter_id != ''), web excluded.
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("all status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var all []historyMessage
|
|
decodeDataSlice(t, body, &all)
|
|
if len(all) != 3 {
|
|
t.Fatalf("all page = %d, want 3 (%+v)", len(all), all)
|
|
}
|
|
if all[0].Content != "tg-group" || all[1].Content != "kook-group" || all[2].Content != "kook-owner" {
|
|
t.Fatalf("all order = %+v, want [tg-group kook-group kook-owner]", all)
|
|
}
|
|
|
|
// Invalid before → 400.
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?before=abc", nil, tok)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("invalid before status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
func TestHistoryDeleteSubSession(t *testing.T) {
|
|
t.Parallel()
|
|
_, ts, jwt, st := newHistoryEnv(t)
|
|
ctx := context.Background()
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
// Create a sub and seed a message.
|
|
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var created struct {
|
|
SessionID string `json:"sessionId"`
|
|
}
|
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &created); err != nil {
|
|
t.Fatalf("decode create data: %v", err)
|
|
}
|
|
if err := st.Append(ctx, history.Message{SessionID: created.SessionID, Role: history.RoleUser, Content: "x", CreatedAt: 1000}); err != nil {
|
|
t.Fatalf("seed sub: %v", err)
|
|
}
|
|
|
|
// Delete it.
|
|
resp, _ = doReq(t, http.MethodDelete, ts.URL+"/api/v1/sessions/"+created.SessionID, nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("delete status = %d, want 200", resp.StatusCode)
|
|
}
|
|
// It is gone from the list.
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("list status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var sessions []historySessionSummary
|
|
decodeDataSlice(t, body, &sessions)
|
|
if len(sessions) != 1 || sessions[0].ID != history.MainSessionID {
|
|
t.Fatalf("sessions after delete = %+v, want [main]", sessions)
|
|
}
|
|
|
|
// Main session is immutable → 403.
|
|
resp, body = doReq(t, http.MethodDelete, ts.URL+"/api/v1/sessions/"+history.MainSessionID, nil, tok)
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("delete main status = %d, want 403 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
wantErrorShape(t, body)
|
|
}
|
|
|
|
func TestHistoryNotWired(t *testing.T) {
|
|
t.Parallel()
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
// Messages/create/delete answer a clear 501 when no history store is wired.
|
|
for _, tc := range []struct {
|
|
method, url string
|
|
want int
|
|
}{
|
|
{http.MethodGet, "/api/v1/sessions/web/messages?limit=2", http.StatusNotImplemented},
|
|
{http.MethodPost, "/api/v1/sessions", http.StatusNotImplemented},
|
|
} {
|
|
resp, body := doReq(t, tc.method, ts.URL+tc.url, nil, tok)
|
|
if resp.StatusCode != tc.want {
|
|
t.Fatalf("%s %s status = %d, want %d (body=%s)", tc.method, tc.url, resp.StatusCode, tc.want, body)
|
|
}
|
|
if !strings.Contains(string(body), "not_implemented") {
|
|
t.Fatalf("%s %s body = %s, want not_implemented code", tc.method, tc.url, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHistoryRBAC(t *testing.T) {
|
|
t.Parallel()
|
|
_, ts, jwt, _ := newHistoryEnv(t)
|
|
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
|
|
owner := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
// Viewer can list session metadata (readAuth) but not read/write history content.
|
|
resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, viewer)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("viewer list sessions status = %d, want 200", resp.StatusCode)
|
|
}
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/web/messages", nil, viewer)
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("viewer messages status = %d, want 403 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, viewer)
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("viewer create status = %d, want 403 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
// Owner can read messages.
|
|
resp, _ = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/web/messages?limit=2", nil, owner)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("owner messages status = %d, want 200", resp.StatusCode)
|
|
}
|
|
}
|