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.
381 lines
12 KiB
Go
381 lines
12 KiB
Go
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)
|
|
}
|
|
}
|