- runtime/history: Store + HistoryStore 接口(Append/List 游标分页/Recent/DeleteSession/NewSubSession/ListSessions),主会话固定 web、次级 web:<uuid>
- db 迁移 0003: chat_sessions + chat_messages + idx_chat_msgs_session_created(事务幂等)
- gateway: GET/POST /sessions、GET /sessions/{id}/messages 热分页、DELETE /sessions/{id}(主不可删 403);Deps.History 可空注入
- 编排层落库: session_persist.go(submitInternal 写用户消息、completeTurn 写助手消息,拒绝的消息不落库)
- WS: wsEvent 加 sessionId 定向 + history 回放帧(握手上行后回放主会话最新 50 条,thinking 剥离)
- wire.go 装配 History 到 gateway.Deps.History
- 端到端实测: POST /sessions 建次级 201 / GET /sessions 列表 / POST /chat 后 /messages 读到 user+assistant / DELETE 次级成功 / DELETE 主会话 403
205 lines
5.7 KiB
Go
205 lines
5.7 KiB
Go
package history
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
|
)
|
|
|
|
// testLogger returns a logger that writes nowhere, keeping test output clean.
|
|
func testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// newStore opens an isolated in-memory DB and returns a Store on it.
|
|
func newStore(t *testing.T) *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 := NewStore(ctx, conn, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
return st
|
|
}
|
|
|
|
func TestNewStoreNilDB(t *testing.T) {
|
|
t.Parallel()
|
|
if _, err := NewStore(context.Background(), nil, testLogger()); !errors.Is(err, ErrNilDB) {
|
|
t.Fatalf("err = %v, want wrap of ErrNilDB", err)
|
|
}
|
|
}
|
|
|
|
func TestAppendAndListNewestFirst(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
ctx := context.Background()
|
|
|
|
for i, ts := range []int64{1000, 2000, 3000} {
|
|
m := Message{SessionID: MainSessionID, Role: RoleUser, Content: "hi", CreatedAt: ts}
|
|
if err := st.Append(ctx, m); err != nil {
|
|
t.Fatalf("append %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
msgs, err := st.List(ctx, MainSessionID, 0, 50)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(msgs) != 3 {
|
|
t.Fatalf("len = %d, want 3", len(msgs))
|
|
}
|
|
// Newest first.
|
|
if msgs[0].CreatedAt != 3000 || msgs[1].CreatedAt != 2000 || msgs[2].CreatedAt != 1000 {
|
|
t.Fatalf("order = %+v, want [3000 2000 1000]", []int64{msgs[0].CreatedAt, msgs[1].CreatedAt, msgs[2].CreatedAt})
|
|
}
|
|
// Id/timestamp assigned when the caller omits them.
|
|
empty := Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"}
|
|
if err := st.Append(ctx, empty); err != nil {
|
|
t.Fatalf("append no ts: %v", err)
|
|
}
|
|
msgs, err = st.List(ctx, MainSessionID, 0, 50)
|
|
if err != nil {
|
|
t.Fatalf("list after append: %v", err)
|
|
}
|
|
if msgs[0].ID == "" || msgs[0].CreatedAt == 0 {
|
|
t.Fatalf("append did not fill id/created_at: %+v", msgs[0])
|
|
}
|
|
}
|
|
|
|
func TestListPaginationBefore(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
ctx := context.Background()
|
|
|
|
for _, ts := range []int64{1000, 2000, 3000, 4000, 5000, 6000} {
|
|
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "x", CreatedAt: ts}); err != nil {
|
|
t.Fatalf("append %d: %v", ts, err)
|
|
}
|
|
}
|
|
|
|
// First page: newest 2.
|
|
page, err := st.List(ctx, MainSessionID, 0, 2)
|
|
if err != nil {
|
|
t.Fatalf("first page: %v", err)
|
|
}
|
|
if len(page) != 2 || page[0].CreatedAt != 6000 || page[1].CreatedAt != 5000 {
|
|
t.Fatalf("first page = %+v, want [6000 5000]", page)
|
|
}
|
|
// Cursor on the page's oldest (5000) → older page [4000 3000].
|
|
next, err := st.List(ctx, MainSessionID, page[1].CreatedAt, 2)
|
|
if err != nil {
|
|
t.Fatalf("next page: %v", err)
|
|
}
|
|
if len(next) != 2 || next[0].CreatedAt != 4000 || next[1].CreatedAt != 3000 {
|
|
t.Fatalf("next page = %+v, want [4000 3000]", next)
|
|
}
|
|
// Past the start → empty.
|
|
tail, err := st.List(ctx, MainSessionID, 1000, 50)
|
|
if err != nil {
|
|
t.Fatalf("tail: %v", err)
|
|
}
|
|
if len(tail) != 0 {
|
|
t.Fatalf("tail = %+v, want empty", tail)
|
|
}
|
|
}
|
|
|
|
func TestRecentChronological(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
ctx := context.Background()
|
|
|
|
for _, ts := range []int64{1000, 2000, 3000, 4000, 5000} {
|
|
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "x", CreatedAt: ts}); err != nil {
|
|
t.Fatalf("append %d: %v", ts, err)
|
|
}
|
|
}
|
|
recent, err := st.Recent(ctx, MainSessionID, 3)
|
|
if err != nil {
|
|
t.Fatalf("recent: %v", err)
|
|
}
|
|
if len(recent) != 3 || recent[0].CreatedAt != 3000 || recent[1].CreatedAt != 4000 || recent[2].CreatedAt != 5000 {
|
|
t.Fatalf("recent = %+v, want [3000 4000 5000]", recent)
|
|
}
|
|
}
|
|
|
|
func TestNewSubSessionAndListSessions(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
ctx := context.Background()
|
|
|
|
// Main session exists by default.
|
|
sessions, err := st.ListSessions(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list sessions initial: %v", err)
|
|
}
|
|
if len(sessions) != 1 || sessions[0].ID != MainSessionID || sessions[0].Type != SessionMain {
|
|
t.Fatalf("initial sessions = %+v, want [main web]", sessions)
|
|
}
|
|
|
|
id, err := st.NewSubSession(ctx, "owner")
|
|
if err != nil {
|
|
t.Fatalf("new sub: %v", err)
|
|
}
|
|
if !strings.HasPrefix(id, MainSessionID+":") {
|
|
t.Fatalf("sub id = %q, want prefix web:", id)
|
|
}
|
|
|
|
sessions, err = st.ListSessions(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list sessions: %v", err)
|
|
}
|
|
if len(sessions) != 2 || sessions[0].ID != MainSessionID || sessions[0].Type != SessionMain {
|
|
t.Fatalf("sessions[0] = %+v, want main web first", sessions[0])
|
|
}
|
|
if sessions[1].ID != id || sessions[1].Type != SessionSub {
|
|
t.Fatalf("sessions[1] = %+v, want sub %s", sessions[1], id)
|
|
}
|
|
}
|
|
|
|
func TestDeleteSession(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
ctx := context.Background()
|
|
|
|
sub, err := st.NewSubSession(ctx, "owner")
|
|
if err != nil {
|
|
t.Fatalf("new sub: %v", err)
|
|
}
|
|
if err := st.Append(ctx, Message{SessionID: sub, Role: RoleUser, Content: "x", CreatedAt: 1000}); err != nil {
|
|
t.Fatalf("append: %v", err)
|
|
}
|
|
|
|
if err := st.DeleteSession(ctx, sub); err != nil {
|
|
t.Fatalf("delete sub: %v", err)
|
|
}
|
|
msgs, err := st.List(ctx, sub, 0, 50)
|
|
if err != nil {
|
|
t.Fatalf("list after delete: %v", err)
|
|
}
|
|
if len(msgs) != 0 {
|
|
t.Fatalf("messages after delete = %+v, want empty", msgs)
|
|
}
|
|
sessions, err := st.ListSessions(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list sessions after delete: %v", err)
|
|
}
|
|
if len(sessions) != 1 {
|
|
t.Fatalf("sessions after delete = %+v, want [main]", sessions)
|
|
}
|
|
|
|
// Main session is immutable.
|
|
if err := st.DeleteSession(ctx, MainSessionID); !errors.Is(err, ErrImmutableMain) {
|
|
t.Fatalf("delete main err = %v, want ErrImmutableMain", err)
|
|
}
|
|
}
|