package session import ( "context" "errors" "testing" "time" domain "knowfoolery/backend/services/game-session-service/internal/domain/session" sharederrors "knowfoolery/backend/shared/domain/errors" ) // fakeRepo is an in-memory repository used by application unit tests. type fakeRepo struct { sessions map[string]*domain.GameSession attempts []*domain.SessionAttempt events []*domain.SessionEvent } func newFakeRepo() *fakeRepo { return &fakeRepo{ sessions: map[string]*domain.GameSession{}, attempts: make([]*domain.SessionAttempt, 0), events: make([]*domain.SessionEvent, 0), } } func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil } func (r *fakeRepo) CreateSession(ctx context.Context, session *domain.GameSession) (*domain.GameSession, error) { session.ID = "sess-1" now := time.Now().UTC() session.CreatedAt = now session.UpdatedAt = now cp := *session r.sessions[cp.ID] = &cp return &cp, nil } func (r *fakeRepo) GetSessionByID(ctx context.Context, id string) (*domain.GameSession, error) { s, ok := r.sessions[id] if !ok { return nil, domain.ErrSessionNotFound } cp := *s return &cp, nil } func (r *fakeRepo) GetActiveSessionByPlayerID(ctx context.Context, playerID string) (*domain.GameSession, error) { for _, s := range r.sessions { if s.PlayerID == playerID && s.Status == domain.StatusActive { cp := *s return &cp, nil } } return nil, domain.ErrSessionNotFound } func (r *fakeRepo) UpdateSession(ctx context.Context, session *domain.GameSession) (*domain.GameSession, error) { if _, ok := r.sessions[session.ID]; !ok { return nil, domain.ErrSessionNotFound } cp := *session cp.UpdatedAt = time.Now().UTC() r.sessions[cp.ID] = &cp return &cp, nil } func (r *fakeRepo) CreateAttempt(ctx context.Context, attempt *domain.SessionAttempt) error { cp := *attempt r.attempts = append(r.attempts, &cp) return nil } func (r *fakeRepo) CreateEvent(ctx context.Context, event *domain.SessionEvent) error { cp := *event r.events = append(r.events, &cp) return nil } func (r *fakeRepo) ListQuestionIDsForSession(ctx context.Context, sessionID string) ([]string, error) { seen := map[string]bool{} ids := make([]string, 0) for _, a := range r.attempts { if a.SessionID != sessionID { continue } if seen[a.QuestionID] { continue } seen[a.QuestionID] = true ids = append(ids, a.QuestionID) } return ids, nil } // fakeQuestionBank is a deterministic question-bank test double. type fakeQuestionBank struct { questions []SessionQuestion answerOK bool } func (f *fakeQuestionBank) GetRandomQuestion( ctx context.Context, exclusions []string, theme, difficulty string, ) (*SessionQuestion, error) { excluded := map[string]bool{} for _, id := range exclusions { excluded[id] = true } for _, q := range f.questions { if excluded[q.ID] { continue } cp := q return &cp, nil } return nil, sharederrors.New(sharederrors.CodeNoQuestionsAvailable, "no questions available") } func (f *fakeQuestionBank) GetQuestionByID(ctx context.Context, id string) (*SessionQuestion, error) { for _, q := range f.questions { if q.ID == id { cp := q return &cp, nil } } return nil, sharederrors.New(sharederrors.CodeQuestionNotFound, "question not found") } func (f *fakeQuestionBank) ValidateAnswer( ctx context.Context, questionID, answer string, ) (*AnswerValidationResult, error) { return &AnswerValidationResult{Matched: f.answerOK, Score: 1}, nil } // fakeUserClient returns a fixed profile for unit tests. type fakeUserClient struct { profile UserProfile } func (f *fakeUserClient) GetUserProfile(ctx context.Context, userID, bearerToken string) (*UserProfile, error) { p := f.profile if p.ID == "" { p.ID = userID } return &p, nil } // fakeStateStore is an in-memory state/lock double. type fakeStateStore struct { active map[string]string timers map[string]time.Time locks map[string]bool } func newFakeStateStore() *fakeStateStore { return &fakeStateStore{ active: map[string]string{}, timers: map[string]time.Time{}, locks: map[string]bool{}, } } func (s *fakeStateStore) GetActiveSession(ctx context.Context, playerID string) (string, bool) { id, ok := s.active[playerID] return id, ok } func (s *fakeStateStore) SetActiveSession(ctx context.Context, playerID, sessionID string, ttl time.Duration) error { s.active[playerID] = sessionID return nil } func (s *fakeStateStore) ClearActiveSession(ctx context.Context, playerID string) error { delete(s.active, playerID) return nil } func (s *fakeStateStore) SetTimer(ctx context.Context, sessionID string, expiresAt time.Time, ttl time.Duration) error { s.timers[sessionID] = expiresAt return nil } func (s *fakeStateStore) GetTimer(ctx context.Context, sessionID string) (time.Time, bool) { t, ok := s.timers[sessionID] return t, ok } func (s *fakeStateStore) ClearTimer(ctx context.Context, sessionID string) error { delete(s.timers, sessionID) return nil } func (s *fakeStateStore) AcquireLock(ctx context.Context, sessionID string, ttl time.Duration) bool { if s.locks[sessionID] { return false } s.locks[sessionID] = true return true } func (s *fakeStateStore) ReleaseLock(ctx context.Context, sessionID string) { delete(s.locks, sessionID) } // TestStartSessionCreatesActiveSessionAndFirstQuestion verifies start initializes active session state. func TestStartSessionCreatesActiveSessionAndFirstQuestion(t *testing.T) { svc := NewService( newFakeRepo(), &fakeQuestionBank{questions: []SessionQuestion{{ID: "q1", Text: "t", Hint: "h"}}}, &fakeUserClient{profile: UserProfile{ID: "user-1", DisplayName: "Alice", EmailVerified: true}}, newFakeStateStore(), Config{}, ) res, err := svc.StartSession(context.Background(), StartSessionInput{PlayerID: "user-1", BearerToken: "tok"}) if err != nil { t.Fatalf("StartSession returned error: %v", err) } if res.Session.Status != string(domain.StatusActive) { t.Fatalf("session status=%s want=%s", res.Session.Status, domain.StatusActive) } if res.Session.CurrentQuestionID != "q1" { t.Fatalf("current question id=%s want=q1", res.Session.CurrentQuestionID) } if res.Session.QuestionsAsked != 1 { t.Fatalf("questions asked=%d want=1", res.Session.QuestionsAsked) } } // TestSubmitAnswerScoresAndAdvances verifies correct answer awards score and advances question. func TestSubmitAnswerScoresAndAdvances(t *testing.T) { repo := newFakeRepo() svc := NewService( repo, &fakeQuestionBank{ questions: []SessionQuestion{ {ID: "q1", Text: "q1", Hint: "h1"}, {ID: "q2", Text: "q2", Hint: "h2"}, }, answerOK: true, }, &fakeUserClient{profile: UserProfile{ID: "user-1", DisplayName: "Alice", EmailVerified: true}}, newFakeStateStore(), Config{MinAnswerLatencyMs: 0}, ) start, err := svc.StartSession(context.Background(), StartSessionInput{PlayerID: "user-1"}) if err != nil { t.Fatalf("StartSession returned error: %v", err) } res, err := svc.SubmitAnswer(context.Background(), SubmitAnswerInput{ SessionID: start.Session.ID, Answer: "answer", }) if err != nil { t.Fatalf("SubmitAnswer returned error: %v", err) } if !res.Correct { t.Fatalf("correct=%v want=true", res.Correct) } if res.AwardedScore != 2 { t.Fatalf("awarded score=%d want=2", res.AwardedScore) } if res.NextQuestion == nil || res.NextQuestion.ID != "q2" { t.Fatalf("next question=%v want=q2", res.NextQuestion) } if len(repo.attempts) != 1 { t.Fatalf("attempts=%d want=1", len(repo.attempts)) } } // TestRequestHintOnlyOnce verifies hint can only be requested once per current question. func TestRequestHintOnlyOnce(t *testing.T) { svc := NewService( newFakeRepo(), &fakeQuestionBank{questions: []SessionQuestion{{ID: "q1", Text: "q1", Hint: "first hint"}}}, &fakeUserClient{profile: UserProfile{ID: "user-1", DisplayName: "Alice", EmailVerified: true}}, newFakeStateStore(), Config{}, ) start, err := svc.StartSession(context.Background(), StartSessionInput{PlayerID: "user-1"}) if err != nil { t.Fatalf("StartSession returned error: %v", err) } first, err := svc.RequestHint(context.Background(), RequestHintInput{SessionID: start.Session.ID}) if err != nil { t.Fatalf("RequestHint returned error: %v", err) } if first.Hint != "first hint" { t.Fatalf("hint=%q want=%q", first.Hint, "first hint") } _, err = svc.RequestHint(context.Background(), RequestHintInput{SessionID: start.Session.ID}) if !errors.Is(err, domain.ErrHintAlreadyUsed) { t.Fatalf("second RequestHint err=%v want ErrHintAlreadyUsed", err) } } // TestSubmitAnswerRejectsRapidAnswers verifies anti-cheat latency guard blocks rapid submissions. func TestSubmitAnswerRejectsRapidAnswers(t *testing.T) { repo := newFakeRepo() svc := NewService( repo, &fakeQuestionBank{questions: []SessionQuestion{{ID: "q1", Text: "q1"}}, answerOK: true}, &fakeUserClient{profile: UserProfile{ID: "user-1", DisplayName: "Alice", EmailVerified: true}}, newFakeStateStore(), Config{MinAnswerLatencyMs: 1_000}, ) start, err := svc.StartSession(context.Background(), StartSessionInput{PlayerID: "user-1"}) if err != nil { t.Fatalf("StartSession returned error: %v", err) } _, err = svc.SubmitAnswer(context.Background(), SubmitAnswerInput{SessionID: start.Session.ID, Answer: "a"}) if !errors.Is(err, domain.ErrRapidAnswer) { t.Fatalf("SubmitAnswer err=%v want ErrRapidAnswer", err) } if len(repo.events) == 0 || repo.events[0].EventType != "rapid_answer" { t.Fatalf("events=%v want rapid_answer event", repo.events) } } // TestSubmitAnswerTimeoutTransition verifies expired sessions transition to timed_out on mutation. func TestSubmitAnswerTimeoutTransition(t *testing.T) { svc := NewService( newFakeRepo(), &fakeQuestionBank{questions: []SessionQuestion{{ID: "q1", Text: "q1"}}, answerOK: true}, &fakeUserClient{profile: UserProfile{ID: "user-1", DisplayName: "Alice", EmailVerified: true}}, newFakeStateStore(), Config{SessionDuration: time.Millisecond, MinAnswerLatencyMs: 0}, ) start, err := svc.StartSession(context.Background(), StartSessionInput{PlayerID: "user-1"}) if err != nil { t.Fatalf("StartSession returned error: %v", err) } time.Sleep(5 * time.Millisecond) _, err = svc.SubmitAnswer(context.Background(), SubmitAnswerInput{SessionID: start.Session.ID, Answer: "a"}) if !errors.Is(err, domain.ErrSessionExpired) { t.Fatalf("SubmitAnswer err=%v want ErrSessionExpired", err) } } // TestSubmitAnswerAdvancesAfterMaxAttempts verifies incorrect answer flow advances after max attempts. func TestSubmitAnswerAdvancesAfterMaxAttempts(t *testing.T) { svc := NewService( newFakeRepo(), &fakeQuestionBank{ questions: []SessionQuestion{ {ID: "q1", Text: "q1"}, {ID: "q2", Text: "q2"}, }, answerOK: false, }, &fakeUserClient{profile: UserProfile{ID: "user-1", DisplayName: "Alice", EmailVerified: true}}, newFakeStateStore(), Config{MinAnswerLatencyMs: 0, MaxAttempts: 3}, ) start, err := svc.StartSession(context.Background(), StartSessionInput{PlayerID: "user-1"}) if err != nil { t.Fatalf("StartSession returned error: %v", err) } for i := 0; i < 2; i++ { if _, err := svc.SubmitAnswer(context.Background(), SubmitAnswerInput{ SessionID: start.Session.ID, Answer: "wrong", }); err != nil { t.Fatalf("SubmitAnswer attempt %d returned error: %v", i+1, err) } } last, err := svc.SubmitAnswer(context.Background(), SubmitAnswerInput{ SessionID: start.Session.ID, Answer: "wrong", }) if err != nil { t.Fatalf("SubmitAnswer third attempt returned error: %v", err) } if last.NextQuestion == nil || last.NextQuestion.ID != "q2" { t.Fatalf("next question=%v want=q2", last.NextQuestion) } }