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) } }