You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

412 lines
12 KiB
Go

package session
// service_test.go contains tests for backend behavior.
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
}
// newFakeRepo is a test helper.
func newFakeRepo() *fakeRepo {
return &fakeRepo{
sessions: map[string]*domain.GameSession{},
attempts: make([]*domain.SessionAttempt, 0),
events: make([]*domain.SessionEvent, 0),
}
}
// EnsureSchema is a test helper.
func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil }
// CreateSession is a test helper.
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
}
// GetSessionByID is a test helper.
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
}
// GetActiveSessionByPlayerID is a test helper.
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
}
// UpdateSession is a test helper.
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
}
// CreateAttempt is a test helper.
func (r *fakeRepo) CreateAttempt(ctx context.Context, attempt *domain.SessionAttempt) error {
cp := *attempt
r.attempts = append(r.attempts, &cp)
return nil
}
// CreateEvent is a test helper.
func (r *fakeRepo) CreateEvent(ctx context.Context, event *domain.SessionEvent) error {
cp := *event
r.events = append(r.events, &cp)
return nil
}
// ListQuestionIDsForSession is a test helper.
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
}
// GetRandomQuestion is a test helper.
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")
}
// GetQuestionByID is a test helper.
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")
}
// ValidateAnswer is a test helper.
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
}
// GetUserProfile is a test helper.
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
}
// newFakeStateStore is a test helper.
func newFakeStateStore() *fakeStateStore {
return &fakeStateStore{
active: map[string]string{},
timers: map[string]time.Time{},
locks: map[string]bool{},
}
}
// GetActiveSession is a test helper.
func (s *fakeStateStore) GetActiveSession(ctx context.Context, playerID string) (string, bool) {
id, ok := s.active[playerID]
return id, ok
}
// SetActiveSession is a test helper.
func (s *fakeStateStore) SetActiveSession(ctx context.Context, playerID, sessionID string, ttl time.Duration) error {
s.active[playerID] = sessionID
return nil
}
// ClearActiveSession is a test helper.
func (s *fakeStateStore) ClearActiveSession(ctx context.Context, playerID string) error {
delete(s.active, playerID)
return nil
}
// SetTimer is a test helper.
func (s *fakeStateStore) SetTimer(ctx context.Context, sessionID string, expiresAt time.Time, ttl time.Duration) error {
s.timers[sessionID] = expiresAt
return nil
}
// GetTimer is a test helper.
func (s *fakeStateStore) GetTimer(ctx context.Context, sessionID string) (time.Time, bool) {
t, ok := s.timers[sessionID]
return t, ok
}
// ClearTimer is a test helper.
func (s *fakeStateStore) ClearTimer(ctx context.Context, sessionID string) error {
delete(s.timers, sessionID)
return nil
}
// AcquireLock is a test helper.
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
}
// ReleaseLock is a test helper.
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)
}
}