mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-01 23:15:27 +08:00
484 lines
14 KiB
Go
484 lines
14 KiB
Go
package chathistory
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func blockDetailDir(t *testing.T, detailDir string) func() {
|
|
t.Helper()
|
|
blockedDir := detailDir + ".blocked"
|
|
if err := os.RemoveAll(blockedDir); err != nil {
|
|
t.Fatalf("remove blocked detail dir failed: %v", err)
|
|
}
|
|
if err := os.Rename(detailDir, blockedDir); err != nil {
|
|
t.Fatalf("move detail dir aside failed: %v", err)
|
|
}
|
|
if err := os.RemoveAll(detailDir); err != nil {
|
|
t.Fatalf("remove blocked detail path failed: %v", err)
|
|
}
|
|
if err := os.WriteFile(detailDir, []byte("blocked"), 0o644); err != nil {
|
|
t.Fatalf("write blocked detail path failed: %v", err)
|
|
}
|
|
var once sync.Once
|
|
return func() {
|
|
t.Helper()
|
|
once.Do(func() {
|
|
if err := os.RemoveAll(detailDir); err != nil {
|
|
t.Fatalf("remove blocking detail path failed: %v", err)
|
|
}
|
|
if err := os.Rename(blockedDir, detailDir); err != nil {
|
|
t.Fatalf("restore detail dir failed: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStoreCreatesAndPersistsEntries(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "chat_history.json")
|
|
store := New(path)
|
|
|
|
started, err := store.Start(StartParams{
|
|
CallerID: "caller:abc",
|
|
AccountID: "user@example.com",
|
|
Model: "deepseek-chat",
|
|
Stream: true,
|
|
UserInput: "hello",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("start entry failed: %v", err)
|
|
}
|
|
|
|
updated, err := store.Update(started.ID, UpdateParams{
|
|
Status: "success",
|
|
ReasoningContent: "thinking",
|
|
Content: "answer",
|
|
StatusCode: 200,
|
|
ElapsedMs: 321,
|
|
FinishReason: "stop",
|
|
Usage: map[string]any{"total_tokens": 9},
|
|
Completed: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update entry failed: %v", err)
|
|
}
|
|
if updated.Status != "success" || updated.Content != "answer" {
|
|
t.Fatalf("unexpected updated entry: %#v", updated)
|
|
}
|
|
|
|
snapshot, err := store.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if snapshot.Limit != DefaultLimit {
|
|
t.Fatalf("unexpected default limit: %d", snapshot.Limit)
|
|
}
|
|
if len(snapshot.Items) != 1 {
|
|
t.Fatalf("expected one item, got %d", len(snapshot.Items))
|
|
}
|
|
if snapshot.Items[0].CompletedAt == 0 {
|
|
t.Fatalf("expected completed_at to be populated")
|
|
}
|
|
if snapshot.Items[0].Preview != "answer" {
|
|
t.Fatalf("expected summary preview=answer, got %#v", snapshot.Items[0])
|
|
}
|
|
|
|
reloaded := New(path)
|
|
reloadedSnapshot, err := reloaded.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("reload snapshot failed: %v", err)
|
|
}
|
|
if len(reloadedSnapshot.Items) != 1 {
|
|
t.Fatalf("unexpected reloaded summaries: %#v", reloadedSnapshot.Items)
|
|
}
|
|
full, err := reloaded.Get(started.ID)
|
|
if err != nil {
|
|
t.Fatalf("get detail failed: %v", err)
|
|
}
|
|
if full.Content != "answer" {
|
|
t.Fatalf("expected detail content=answer, got %#v", full)
|
|
}
|
|
}
|
|
|
|
func TestStoreTrimsToConfiguredLimit(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "chat_history.json")
|
|
store := New(path)
|
|
if _, err := store.SetLimit(10); err != nil {
|
|
t.Fatalf("set limit failed: %v", err)
|
|
}
|
|
|
|
for i := 0; i < 12; i++ {
|
|
entry, err := store.Start(StartParams{Model: "deepseek-chat", UserInput: "msg"})
|
|
if err != nil {
|
|
t.Fatalf("start %d failed: %v", i, err)
|
|
}
|
|
if _, err := store.Update(entry.ID, UpdateParams{Status: "success", Content: "ok", Completed: true}); err != nil {
|
|
t.Fatalf("update %d failed: %v", i, err)
|
|
}
|
|
}
|
|
|
|
snapshot, err := store.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 10 {
|
|
t.Fatalf("expected 10 items, got %d", len(snapshot.Items))
|
|
}
|
|
}
|
|
|
|
func TestStoreDeleteClearAndLimitValidation(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "chat_history.json")
|
|
store := New(path)
|
|
entry, err := store.Start(StartParams{UserInput: "hello"})
|
|
if err != nil {
|
|
t.Fatalf("start failed: %v", err)
|
|
}
|
|
if err := store.Delete(entry.ID); err != nil {
|
|
t.Fatalf("delete failed: %v", err)
|
|
}
|
|
snapshot, err := store.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 0 {
|
|
t.Fatalf("expected empty items after delete, got %d", len(snapshot.Items))
|
|
}
|
|
if _, err := store.SetLimit(999); err == nil {
|
|
t.Fatalf("expected invalid limit error")
|
|
}
|
|
if err := store.Clear(); err != nil {
|
|
t.Fatalf("clear failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStoreDisablePreservesHistoryAndBlocksNewEntries(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "chat_history.json")
|
|
store := New(path)
|
|
|
|
entry, err := store.Start(StartParams{UserInput: "hello"})
|
|
if err != nil {
|
|
t.Fatalf("start failed: %v", err)
|
|
}
|
|
if _, err := store.Update(entry.ID, UpdateParams{Status: "success", Content: "world", Completed: true}); err != nil {
|
|
t.Fatalf("update failed: %v", err)
|
|
}
|
|
|
|
snapshot, err := store.SetLimit(DisabledLimit)
|
|
if err != nil {
|
|
t.Fatalf("disable failed: %v", err)
|
|
}
|
|
if snapshot.Limit != DisabledLimit {
|
|
t.Fatalf("expected disabled limit, got %d", snapshot.Limit)
|
|
}
|
|
if len(snapshot.Items) != 1 {
|
|
t.Fatalf("expected disabled mode to preserve summaries, got %d", len(snapshot.Items))
|
|
}
|
|
if store.Enabled() {
|
|
t.Fatalf("expected store to report disabled")
|
|
}
|
|
if _, err := store.Start(StartParams{UserInput: "later"}); err != ErrDisabled {
|
|
t.Fatalf("expected ErrDisabled, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStoreConcurrentUpdatesKeepSplitFilesValid(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "chat_history.json")
|
|
store := New(path)
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
entry, err := store.Start(StartParams{
|
|
CallerID: "caller:test",
|
|
Model: "deepseek-chat",
|
|
UserInput: "hello",
|
|
})
|
|
if err != nil {
|
|
t.Errorf("start failed: %v", err)
|
|
return
|
|
}
|
|
_, err = store.Update(entry.ID, UpdateParams{
|
|
Status: "success",
|
|
Content: "answer",
|
|
ElapsedMs: int64(idx),
|
|
Completed: true,
|
|
})
|
|
if err != nil {
|
|
t.Errorf("update failed: %v", err)
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
snapshot, err := store.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 8 {
|
|
t.Fatalf("expected 8 items, got %d", len(snapshot.Items))
|
|
}
|
|
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read index failed: %v", err)
|
|
}
|
|
var persisted File
|
|
if err := json.Unmarshal(raw, &persisted); err != nil {
|
|
t.Fatalf("persisted index is invalid json: %v", err)
|
|
}
|
|
if len(persisted.Items) != 8 {
|
|
t.Fatalf("expected persisted items=8, got %d", len(persisted.Items))
|
|
}
|
|
|
|
detailFiles, err := os.ReadDir(path + ".d")
|
|
if err != nil {
|
|
t.Fatalf("read detail dir failed: %v", err)
|
|
}
|
|
if len(detailFiles) != 8 {
|
|
t.Fatalf("expected 8 detail files, got %d", len(detailFiles))
|
|
}
|
|
}
|
|
|
|
func TestStoreAutoMigratesLegacyMonolith(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "chat_history.json")
|
|
legacy := legacyFile{
|
|
Version: 1,
|
|
Limit: 20,
|
|
Items: []Entry{{
|
|
ID: "chat_legacy",
|
|
CreatedAt: 1,
|
|
UpdatedAt: 2,
|
|
Status: "success",
|
|
UserInput: "hello",
|
|
Content: "world",
|
|
ReasoningContent: "thinking",
|
|
}},
|
|
}
|
|
body, _ := json.MarshalIndent(legacy, "", " ")
|
|
if err := os.WriteFile(path, body, 0o644); err != nil {
|
|
t.Fatalf("write legacy file failed: %v", err)
|
|
}
|
|
|
|
store := New(path)
|
|
if err := store.Err(); err != nil {
|
|
t.Fatalf("expected legacy migration success, got %v", err)
|
|
}
|
|
snapshot, err := store.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 1 {
|
|
t.Fatalf("expected one migrated summary, got %#v", snapshot.Items)
|
|
}
|
|
full, err := store.Get("chat_legacy")
|
|
if err != nil {
|
|
t.Fatalf("get migrated detail failed: %v", err)
|
|
}
|
|
if full.Content != "world" {
|
|
t.Fatalf("expected migrated detail content preserved, got %#v", full)
|
|
}
|
|
}
|
|
|
|
func TestStoreAutoMigratesMetadataOnlyLegacyMonolith(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "chat_history.json")
|
|
legacy := legacyFile{
|
|
Version: 1,
|
|
Limit: 20,
|
|
Items: []Entry{{
|
|
ID: "chat_metadata_only",
|
|
Revision: 0,
|
|
CreatedAt: 1,
|
|
UpdatedAt: 2,
|
|
Status: "error",
|
|
CallerID: "caller:test",
|
|
AccountID: "acct:test",
|
|
Model: "deepseek-chat",
|
|
Stream: true,
|
|
UserInput: "hello",
|
|
Error: "boom",
|
|
StatusCode: 500,
|
|
ElapsedMs: 12,
|
|
FinishReason: "error",
|
|
}},
|
|
}
|
|
body, _ := json.MarshalIndent(legacy, "", " ")
|
|
if err := os.WriteFile(path, body, 0o644); err != nil {
|
|
t.Fatalf("write legacy file failed: %v", err)
|
|
}
|
|
|
|
store := New(path)
|
|
if err := store.Err(); err != nil {
|
|
t.Fatalf("expected legacy metadata-only migration success, got %v", err)
|
|
}
|
|
snapshot, err := store.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 1 {
|
|
t.Fatalf("expected one migrated summary, got %#v", snapshot.Items)
|
|
}
|
|
full, err := store.Get("chat_metadata_only")
|
|
if err != nil {
|
|
t.Fatalf("get migrated detail failed: %v", err)
|
|
}
|
|
if full.Error != "boom" || full.UserInput != "hello" {
|
|
t.Fatalf("expected metadata-only legacy fields preserved, got %#v", full)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(store.DetailDir(), "chat_metadata_only.json")); err != nil {
|
|
t.Fatalf("expected migrated detail file to exist: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStoreLegacyMigrationBestEffortWhenRewriteFails(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "chat_history.json")
|
|
longID := "chat_" + strings.Repeat("x", 320)
|
|
legacy := legacyFile{
|
|
Version: 1,
|
|
Limit: 20,
|
|
Items: []Entry{{
|
|
ID: longID,
|
|
CreatedAt: 1,
|
|
UpdatedAt: 2,
|
|
Status: "success",
|
|
UserInput: "hello",
|
|
Content: "world",
|
|
}},
|
|
}
|
|
body, err := json.MarshalIndent(legacy, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("marshal legacy file failed: %v", err)
|
|
}
|
|
if err := os.WriteFile(path, body, 0o644); err != nil {
|
|
t.Fatalf("write legacy file failed: %v", err)
|
|
}
|
|
|
|
store := New(path)
|
|
if err := store.Err(); err != nil {
|
|
t.Fatalf("expected store to stay usable after migration writeback failure, got %v", err)
|
|
}
|
|
if !store.Enabled() {
|
|
t.Fatal("expected store to remain enabled after best-effort migration")
|
|
}
|
|
|
|
snapshot, err := store.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("snapshot failed: %v", err)
|
|
}
|
|
if len(snapshot.Items) != 1 || snapshot.Items[0].ID != longID {
|
|
t.Fatalf("unexpected snapshot after best-effort migration: %#v", snapshot.Items)
|
|
}
|
|
full, err := store.Get(longID)
|
|
if err != nil {
|
|
t.Fatalf("get migrated detail failed: %v", err)
|
|
}
|
|
if full.Content != "world" {
|
|
t.Fatalf("expected migrated content to stay in memory, got %#v", full)
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(store.DetailDir(), longID+".json")); statErr == nil {
|
|
t.Fatal("expected detail write to fail for overlong legacy id")
|
|
}
|
|
}
|
|
|
|
func TestStoreTransientPersistenceFailureDoesNotLatch(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "chat_history.json")
|
|
store := New(path)
|
|
|
|
first, err := store.Start(StartParams{UserInput: "first"})
|
|
if err != nil {
|
|
t.Fatalf("start first failed: %v", err)
|
|
}
|
|
restore := blockDetailDir(t, store.DetailDir())
|
|
t.Cleanup(restore)
|
|
|
|
blocked, err := store.Start(StartParams{UserInput: "blocked"})
|
|
if err == nil {
|
|
t.Fatalf("expected start failure while detail dir is blocked")
|
|
}
|
|
if blocked.ID == "" {
|
|
t.Fatalf("expected in-memory entry from failed start")
|
|
}
|
|
if err := store.Err(); err != nil {
|
|
t.Fatalf("transient start failure should not latch store error: %v", err)
|
|
}
|
|
if _, err := store.Update(first.ID, UpdateParams{Status: "success", Content: "one", Completed: true}); err == nil {
|
|
t.Fatalf("expected update failure while detail dir is blocked")
|
|
}
|
|
if err := store.Err(); err != nil {
|
|
t.Fatalf("transient update failure should not latch store error: %v", err)
|
|
}
|
|
|
|
restore()
|
|
|
|
if _, err := store.Update(blocked.ID, UpdateParams{Status: "success", Content: "two", Completed: true}); err != nil {
|
|
t.Fatalf("update after restore failed: %v", err)
|
|
}
|
|
if _, err := store.Start(StartParams{UserInput: "later"}); err != nil {
|
|
t.Fatalf("start after restore failed: %v", err)
|
|
}
|
|
full, err := store.Get(blocked.ID)
|
|
if err != nil {
|
|
t.Fatalf("get restored entry failed: %v", err)
|
|
}
|
|
if full.Content != "two" || full.Status != "success" {
|
|
t.Fatalf("expected restored entry persisted, got %#v", full)
|
|
}
|
|
}
|
|
|
|
func TestStoreWritesOnlyChangedDetailFiles(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "chat_history.json")
|
|
store := New(path)
|
|
|
|
first, err := store.Start(StartParams{UserInput: "one"})
|
|
if err != nil {
|
|
t.Fatalf("start first failed: %v", err)
|
|
}
|
|
if _, err := store.Update(first.ID, UpdateParams{Status: "success", Content: "first", Completed: true}); err != nil {
|
|
t.Fatalf("update first failed: %v", err)
|
|
}
|
|
second, err := store.Start(StartParams{UserInput: "two"})
|
|
if err != nil {
|
|
t.Fatalf("start second failed: %v", err)
|
|
}
|
|
if _, err := store.Update(second.ID, UpdateParams{Status: "success", Content: "second", Completed: true}); err != nil {
|
|
t.Fatalf("update second failed: %v", err)
|
|
}
|
|
|
|
firstPath := filepath.Join(store.DetailDir(), first.ID+".json")
|
|
secondPath := filepath.Join(store.DetailDir(), second.ID+".json")
|
|
beforeFirst, err := os.ReadFile(firstPath)
|
|
if err != nil {
|
|
t.Fatalf("read first detail before update failed: %v", err)
|
|
}
|
|
beforeSecond, err := os.ReadFile(secondPath)
|
|
if err != nil {
|
|
t.Fatalf("read second detail before update failed: %v", err)
|
|
}
|
|
|
|
if _, err := store.Update(first.ID, UpdateParams{Status: "success", Content: "first-updated", Completed: true}); err != nil {
|
|
t.Fatalf("update first again failed: %v", err)
|
|
}
|
|
|
|
afterFirst, err := os.ReadFile(firstPath)
|
|
if err != nil {
|
|
t.Fatalf("read first detail after update failed: %v", err)
|
|
}
|
|
afterSecond, err := os.ReadFile(secondPath)
|
|
if err != nil {
|
|
t.Fatalf("read second detail after update failed: %v", err)
|
|
}
|
|
|
|
if bytes.Equal(beforeFirst, afterFirst) {
|
|
t.Fatalf("expected first detail file to change after update")
|
|
}
|
|
if !bytes.Equal(beforeSecond, afterSecond) {
|
|
t.Fatalf("expected untouched detail file to remain byte-identical")
|
|
}
|
|
}
|