Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions internal/modelref/modelref_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package modelref_test

import (
"testing"

"moonbridge/internal/modelref"
)

func TestParse(t *testing.T) {
tests := []struct {
name string
ref string
wantProvider string
wantModel string
}{
{
name: "provider slash model",
ref: "openai/gpt-4o",
wantProvider: "openai",
wantModel: "gpt-4o",
},
{
name: "model paren provider",
ref: "claude-opus-4-6(kiro)",
wantProvider: "kiro",
wantModel: "claude-opus-4-6",
},
{
name: "no separator returns empty provider and original ref",
ref: "gpt-4o",
wantProvider: "",
wantModel: "gpt-4o",
},
{
name: "leading and trailing whitespace is trimmed",
ref: " openai / gpt-4o ",
wantProvider: "openai",
wantModel: "gpt-4o",
},
{
name: "whitespace inside paren form is trimmed",
ref: " claude ( kiro ) ",
wantProvider: "kiro",
wantModel: "claude",
},
{
name: "paren form preferred over slash when both present",
ref: "anthropic/claude(kiro)",
wantProvider: "kiro",
wantModel: "anthropic/claude",
},
{
name: "empty provider inside parens falls back to slash form",
ref: "provider/model()",
wantProvider: "provider",
wantModel: "model()",
},
{
name: "paren form matches even when model contains a slash",
ref: "a/b(provider)",
wantProvider: "provider",
wantModel: "a/b",
},
{
name: "open paren at index zero is not treated as paren form",
ref: "(provider)",
wantProvider: "",
wantModel: "(provider)",
},
{
name: "open paren without closing suffix uses slash form",
ref: "model(provider",
wantProvider: "",
wantModel: "model(provider",
},
{
name: "empty string",
ref: "",
wantProvider: "",
wantModel: "",
},
{
name: "only slash yields empty provider and model",
ref: "/",
wantProvider: "",
wantModel: "",
},
{
name: "slash with empty provider",
ref: "/model",
wantProvider: "",
wantModel: "model",
},
{
name: "slash with empty model",
ref: "provider/",
wantProvider: "provider",
wantModel: "",
},
{
name: "first slash is used to split",
ref: "a/b/c",
wantProvider: "a",
wantModel: "b/c",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotProvider, gotModel := modelref.Parse(tt.ref)
if gotProvider != tt.wantProvider {
t.Errorf("Parse(%q) provider = %q, want %q", tt.ref, gotProvider, tt.wantProvider)
}
if gotModel != tt.wantModel {
t.Errorf("Parse(%q) model = %q, want %q", tt.ref, gotModel, tt.wantModel)
}
})
}
}
220 changes: 220 additions & 0 deletions internal/service/server/session/manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package session_test

import (
"testing"
"time"

"moonbridge/internal/extension/plugin"
sessionmgr "moonbridge/internal/service/server/session"
)

// fakeConfig is a test ConfigAccessor with fixed TTL and max sessions.
type fakeConfig struct {
ttl time.Duration
maxSessions int
}

func (c fakeConfig) SessionTTL() time.Duration { return c.ttl }
func (c fakeConfig) MaxSessions() int { return c.maxSessions }

func newManager(ttl time.Duration, maxSessions int) *sessionmgr.InMemoryManager {
return sessionmgr.NewInMemoryManager(fakeConfig{ttl: ttl, maxSessions: maxSessions}, nil)
}

func TestGetOrCreateCreatesAndReuses(t *testing.T) {
m := newManager(time.Hour, 0)
defer m.Stop()

now := time.Now()
s1 := m.GetOrCreate("a", now)
if s1 == nil {
t.Fatal("GetOrCreate returned nil")
}
if s1.ID != "a" {
t.Errorf("session ID = %q, want a", s1.ID)
}

s2 := m.GetOrCreate("a", now.Add(time.Minute))
if s1 != s2 {
t.Error("GetOrCreate should return the same session instance for the same key")
}

s3 := m.GetOrCreate("b", now)
if s3 == s1 {
t.Error("GetOrCreate should return a distinct session for a different key")
}
}

func TestGetOrCreateInitializesExtensions(t *testing.T) {
m := newManager(time.Hour, 0)
defer m.Stop()

// With a nil plugin registry, ExtensionData is initialized to nil (not left unset).
s := m.GetOrCreate("a", time.Now())
if s.ExtensionData != nil {
t.Errorf("ExtensionData = %v, want nil with nil registry", s.ExtensionData)
}
}

func TestListReturnsSnapshot(t *testing.T) {
m := newManager(time.Hour, 0)
defer m.Stop()

now := time.Now()
m.GetOrCreate("a", now)
m.GetOrCreate("b", now)

infos := m.List()
if len(infos) != 2 {
t.Fatalf("List returned %d sessions, want 2", len(infos))
}
keys := map[string]bool{}
for _, info := range infos {
keys[info.Key] = true
if info.CreatedAt == "" {
t.Errorf("session %q has empty CreatedAt", info.Key)
}
if info.LastUsed == "" {
t.Errorf("session %q has empty LastUsed", info.Key)
}
}
if !keys["a"] || !keys["b"] {
t.Errorf("List keys = %v, want a and b", keys)
}
}

func TestPruneRemovesExpiredSessions(t *testing.T) {
m := newManager(90*time.Minute, 0)
defer m.Stop()

base := time.Now()
m.GetOrCreate("old", base)
m.GetOrCreate("fresh", base.Add(time.Hour))

// Prune at base+2h: "old" is stale (used at base), "fresh" used at base+1h.
m.Prune(base.Add(2 * time.Hour))

infos := m.List()
if len(infos) != 1 {
t.Fatalf("after prune got %d sessions, want 1", len(infos))
}
if infos[0].Key != "fresh" {
t.Errorf("remaining session = %q, want fresh", infos[0].Key)
}
}

func TestGetOrCreatePrunesBeforeLookup(t *testing.T) {
m := newManager(30*time.Minute, 0)
defer m.Stop()

base := time.Now()
first := m.GetOrCreate("a", base)

// Access with a much-later time: the stale entry is pruned and recreated.
second := m.GetOrCreate("a", base.Add(2*time.Hour))
if first == second {
t.Error("expected a fresh session after the previous one expired")
}
}

func TestMaxSessionsEvictsLRU(t *testing.T) {
m := newManager(time.Hour, 2)
defer m.Stop()

base := time.Now()
m.GetOrCreate("a", base)
m.GetOrCreate("b", base.Add(time.Minute))
// Adding a third session should evict the least-recently-used ("a").
m.GetOrCreate("c", base.Add(2*time.Minute))

infos := m.List()
if len(infos) != 2 {
t.Fatalf("got %d sessions, want 2", len(infos))
}
keys := map[string]bool{}
for _, info := range infos {
keys[info.Key] = true
}
if keys["a"] {
t.Error("expected LRU session 'a' to have been evicted")
}
if !keys["b"] || !keys["c"] {
t.Errorf("expected b and c to remain, got %v", keys)
}
}

func TestMaxSessionsReuseUpdatesRecency(t *testing.T) {
m := newManager(time.Hour, 2)
defer m.Stop()

base := time.Now()
m.GetOrCreate("a", base)
m.GetOrCreate("b", base.Add(time.Minute))
// Touch "a" so it becomes most-recently-used.
m.GetOrCreate("a", base.Add(2*time.Minute))
// Adding "c" should now evict "b" instead of "a".
m.GetOrCreate("c", base.Add(3*time.Minute))

keys := map[string]bool{}
for _, info := range m.List() {
keys[info.Key] = true
}
if keys["b"] {
t.Error("expected 'b' to be evicted as LRU")
}
if !keys["a"] || !keys["c"] {
t.Errorf("expected a and c to remain, got %v", keys)
}
}

func TestGetOrCreateWithPluginRegistry(t *testing.T) {
reg := plugin.NewRegistry(nil)
m := sessionmgr.NewInMemoryManager(fakeConfig{ttl: time.Hour}, reg)
defer m.Stop()

now := time.Now()
s1 := m.GetOrCreate("a", now)
if s1 == nil {
t.Fatal("GetOrCreate returned nil")
}
// Reusing the key exercises the ExtensionData backfill branch.
if s2 := m.GetOrCreate("a", now.Add(time.Minute)); s2 != s1 {
t.Error("expected the same session on reuse")
}
}

func TestNewEphemeralWithPluginRegistry(t *testing.T) {
reg := plugin.NewRegistry(nil)
m := sessionmgr.NewInMemoryManager(fakeConfig{ttl: time.Hour}, reg)
defer m.Stop()

if s := m.NewEphemeral(); s == nil {
t.Fatal("NewEphemeral returned nil")
}
}

func TestNewEphemeralIsNotTracked(t *testing.T) {
m := newManager(time.Hour, 0)
defer m.Stop()

s := m.NewEphemeral()
if s == nil {
t.Fatal("NewEphemeral returned nil")
}
if s.ID == "" {
t.Error("ephemeral session should have a generated ID")
}
if len(m.List()) != 0 {
t.Error("ephemeral session must not be tracked by the manager")
}
}

func TestStopIsIdempotentlyCloseable(t *testing.T) {
m := newManager(time.Hour, 0)
m.Stop()
// A second Stop would panic on a double-close; ensure we only call it once
// but that the manager remains usable for reads after stopping.
if got := m.List(); len(got) != 0 {
t.Errorf("List after Stop = %v, want empty", got)
}
}
Loading