package ctxmgr import ( "context" "strconv" "sync" "testing" "time" ) func TestAcquireChatTurn_DuplicateSameBody(t *testing.T) { mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) ctx := context.Background() sessionID := "sess-dup" turnID := "turn-abc" bodyHash := HashTurnBody(sessionID, "hello world") acq1, err := mgr.AcquireChatTurn(ctx, sessionID, turnID, bodyHash, "req-1", 1, 1) if err != nil || !acq1.WonLease { t.Fatalf("first acquire should win lease: %v %+v", err, acq1) } acq2, err := mgr.AcquireChatTurn(ctx, sessionID, turnID, bodyHash, "req-2", 1, 1) if err != nil { t.Fatalf("second acquire err: %v", err) } if acq2.WonLease { t.Fatal("second acquire must not win lease") } if acq2.Record.AttemptCount < 2 { t.Fatalf("attempt_count should increment, got %d", acq2.Record.AttemptCount) } } func TestAcquireChatTurn_ConcurrentDuplicate(t *testing.T) { mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) ctx := context.Background() sessionID := "sess-conc" turnID := "turn-conc" bodyHash := HashTurnBody(sessionID, "concurrent msg") var wg sync.WaitGroup wins := 0 var mu sync.Mutex for i := 0; i < 5; i++ { wg.Add(1) go func(n int) { defer wg.Done() acq, err := mgr.AcquireChatTurn(ctx, sessionID, turnID, bodyHash, "req", 1, 1) if err != nil { t.Errorf("acquire %d: %v", n, err) return } if acq.WonLease { mu.Lock() wins++ mu.Unlock() } }(i) } wg.Wait() if wins != 1 { t.Fatalf("expected exactly 1 winner, got %d", wins) } } func TestAcquireChatTurn_DifferentBodyRejected(t *testing.T) { mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) ctx := context.Background() sessionID := "sess-reuse" turnID := "turn-x" _, err := mgr.AcquireChatTurn(ctx, sessionID, turnID, HashTurnBody(sessionID, "msg-a"), "req-1", 1, 1) if err != nil { t.Fatal(err) } _, err = mgr.AcquireChatTurn(ctx, sessionID, turnID, HashTurnBody(sessionID, "msg-b"), "req-2", 1, 1) if err != ErrIdempotencyKeyReused { t.Fatalf("expected ErrIdempotencyKeyReused, got %v", err) } } func TestAcquireChatTurn_StaleEntriesPruned(t *testing.T) { mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) ctx := context.Background() mgr.mu.Lock() now := time.Now() for i := 0; i < maxMemTurns+50; i++ { key := turnMemKey("sess-prune", "old-"+strconv.Itoa(i)) mgr.turns[key] = &ChatTurnRecord{ ID: int64(i + 1), SessionID: "sess-prune", ClientTurnID: "old-" + strconv.Itoa(i), Status: TurnStatusCompleted, UpdatedAt: now.Add(-3 * time.Hour), } } mgr.mu.Unlock() bodyHash := HashTurnBody("sess-prune", "fresh") _, err := mgr.AcquireChatTurn(ctx, "sess-prune", "fresh-turn", bodyHash, "req-fresh", 1, 1) if err != nil { t.Fatalf("acquire: %v", err) } mgr.mu.RLock() n := len(mgr.turns) mgr.mu.RUnlock() if n > maxMemTurns { t.Fatalf("expected turns pruned under capacity, got %d", n) } }