Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix kyc quiz expired #96

Merged
merged 3 commits into from
Jan 15, 2024
Merged
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
4 changes: 2 additions & 2 deletions cmd/eskimo-hut/kyc.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func (s *service) StartOrContinueKYCStep4Session( //nolint:gocritic,funlen // .
case errors.Is(err, kycquiz.ErrUnknownUser) || errors.Is(err, kycquiz.ErrUnknownSession):
return nil, server.NotFound(err, userNotFoundErrorCode)

case errors.Is(err, kycquiz.ErrSessionExpired), errors.Is(err, kycquiz.ErrSessionFinished), errors.Is(err, kycquiz.ErrSessionFinishedWithError), errors.Is(err, kycquiz.ErrInvalidKYCState): //nolint:lll // .
case errors.Is(err, kycquiz.ErrSessionFinished), errors.Is(err, kycquiz.ErrSessionFinishedWithError), errors.Is(err, kycquiz.ErrInvalidKYCState): //nolint:lll // .
return nil, server.BadRequest(err, raceConditionErrorCode)

case errors.Is(err, kycquiz.ErrUnknownQuestionNumber):
Expand Down Expand Up @@ -244,7 +244,7 @@ func (s *service) TryResetKYCSteps( //nolint:gocritic,funlen,gocognit,revive,cyc
}
case users.QuizKYCStep:
if err := s.quizRepository.SkipQuizSession(ctx, req.Data.UserID); err != nil {
if errors.Is(err, kycquiz.ErrInvalidKYCState) || errors.Is(err, kycquiz.ErrSessionFinished) || errors.Is(err, kycquiz.ErrSessionFinishedWithError) || errors.Is(err, kycquiz.ErrSessionExpired) { //nolint:lll // .
if errors.Is(err, kycquiz.ErrInvalidKYCState) || errors.Is(err, kycquiz.ErrSessionFinished) || errors.Is(err, kycquiz.ErrSessionFinishedWithError) { //nolint:lll // .
log.Error(errors.Wrapf(err, "skipQuizSession failed unexpectedly during tryResetKYCSteps for userID:%v", req.Data.UserID))
err = nil
}
Expand Down
5 changes: 3 additions & 2 deletions kyc/quiz/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ var (
ErrSessionIsAlreadyRunning = newError("another session is already running")
ErrSessionFinished = newError("session closed")
ErrSessionFinishedWithError = newError("session closed with error")
ErrSessionExpired = newError("session expired")
ErrUnknownQuestionNumber = newError("unknown question number")
ErrUnknownSession = newError("unknown session and/or user")
)
Expand All @@ -78,9 +77,11 @@ const (
applicationYamlKey = "kyc/quiz"
)

var ( //nolint:gofumpt //.
var (
//go:embed DDL.sql
ddl string

errSessionExpired = newError("session expired")
)

type (
Expand Down
117 changes: 93 additions & 24 deletions kyc/quiz/quiz.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ func (r *repositoryImpl) SkipQuizSession(ctx context.Context, userID UserID) err
// $1: user_id.
const stmt = `
select
started_at,
ended_at is not null as finished,
ended_successfully
from
Expand All @@ -119,9 +118,8 @@ func (r *repositoryImpl) SkipQuizSession(ctx context.Context, userID UserID) err
now := time.Now()

data, err := storage.ExecOne[struct {
StartedAt *time.Time `db:"started_at"`
Finished bool `db:"finished"`
Success bool `db:"ended_successfully"`
Finished bool `db:"finished"`
Success bool `db:"ended_successfully"`
}](ctx, tx, stmt, userID)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
Expand All @@ -131,14 +129,7 @@ func (r *repositoryImpl) SkipQuizSession(ctx context.Context, userID UserID) err
return errors.Wrap(wrapErrorInTx(err), "failed to get session data")
}

switch {
case data.StartedAt == nil:
return wrapErrorInTx(ErrUnknownSession)

case data.StartedAt.Add(stdlibtime.Duration(r.config.MaxSessionDurationSeconds) * stdlibtime.Second).Before(*now.Time):
return wrapErrorInTx(ErrSessionExpired)

case data.Finished:
if data.Finished {
if data.Success {
return wrapErrorInTx(ErrSessionFinished)
}
Expand Down Expand Up @@ -199,17 +190,63 @@ func wrapErrorInTx(err error) error {
return err
}

func (r *repositoryImpl) StartQuizSession(ctx context.Context, userID UserID, lang string) (*Quiz, error) { //nolint:funlen //.
err := r.CheckUserKYC(ctx, userID)
func (r *repositoryImpl) finishExpiredSession( //nolint:funlen //.
ctx context.Context,
userID UserID,
now *time.Time,
tx storage.QueryExecer,
) (*time.Time, error) {
// $1: user_id.
// $2: max session duration (seconds).
// $3: session cool down (seconds).
const stmt = `
with result as (
update quiz_sessions
set
ended_at = now(),
ended_successfully = false
where
user_id = $1 and
ended_at is null and
started_at + make_interval(secs => $2) < now()
returning *
)
insert into failed_quiz_sessions (started_at, ended_at, questions, answers, language, user_id, skipped)
select
result.started_at,
result.ended_at,
result.questions,
result.answers,
result.language,
result.user_id,
false
from
result
returning
ended_at + make_interval(secs => $3) as cooldown_at
`
data, err := storage.ExecOne[struct {
CooldownAt *time.Time `db:"cooldown_at"`
}](ctx, tx, stmt, userID, r.config.MaxSessionDurationSeconds, r.config.SessionCoolDownSeconds)
if err != nil {
return nil, err
}
if errors.Is(err, storage.ErrNotFound) {
err = nil
}

questions, err := r.SelectQuestions(ctx, r.DB, lang)
if err != nil {
return nil, err
}

return data.CooldownAt, errors.Wrapf(r.modifyUser(ctx, false, now, userID), "failed to modifyUser")
}

func (r *repositoryImpl) startNewSession( //nolint:funlen,revive //.
ctx context.Context,
userID UserID,
now *time.Time,
tx storage.QueryExecer,
lang string,
questions []*Question,
) (*Quiz, error) {
// $1: user_id.
// $2: language.
// $3: questions.
Expand Down Expand Up @@ -294,7 +331,7 @@ func (r *repositoryImpl) StartQuizSession(ctx context.Context, userID UserID, la
ActiveEndedAt *time.Time `db:"active_ended_at"`
UpsertStartedAt *time.Time `db:"upsert_started_at"`
UpsertDeadline *time.Time `db:"upsert_deadline"`
}](ctx, r.DB, stmt, userID, lang, questionsToSlice(questions), r.config.SessionCoolDownSeconds, r.config.MaxSessionDurationSeconds)
}](ctx, tx, stmt, userID, lang, questionsToSlice(questions), r.config.SessionCoolDownSeconds, r.config.MaxSessionDurationSeconds)
if err != nil {
if errors.Is(err, storage.ErrRelationNotFound) {
err = ErrUnknownUser
Expand All @@ -303,7 +340,6 @@ func (r *repositoryImpl) StartQuizSession(ctx context.Context, userID UserID, la
return nil, errors.Wrap(err, "failed to start session")
}

now := stdlibtime.Now().Truncate(stdlibtime.Second).UTC()
switch {
case data.FailedAt != nil: // Failed session is still in cool down.
return nil, errors.Wrapf(ErrSessionFinishedWithError, "wait until %v",
Expand All @@ -318,8 +354,8 @@ func (r *repositoryImpl) StartQuizSession(ctx context.Context, userID UserID, la
return nil, ErrSessionFinishedWithError
}

if data.ActiveDeadline.After(now) {
return nil, errors.Wrapf(ErrSessionIsAlreadyRunning, "wait %s before next session", data.ActiveDeadline.Sub(now))
if data.ActiveDeadline.After(*now.Time) {
return nil, errors.Wrapf(ErrSessionIsAlreadyRunning, "wait %s before next session", data.ActiveDeadline.Sub(*now.Time))
}

case data.UpsertStartedAt != nil: // New session is started.
Expand All @@ -335,6 +371,34 @@ func (r *repositoryImpl) StartQuizSession(ctx context.Context, userID UserID, la
panic("unreachable: " + userID)
}

func (r *repositoryImpl) StartQuizSession(ctx context.Context, userID UserID, lang string) (quiz *Quiz, err error) {
questions, err := r.SelectQuestions(ctx, r.DB, lang)
if err != nil {
return nil, err
}

err = storage.DoInTransaction(ctx, r.DB, func(tx storage.QueryExecer) error {
now := time.Now()
cooldown, fErr := r.finishExpiredSession(ctx, userID, now, tx)
if fErr != nil {
return wrapErrorInTx(fErr)
} else if cooldown != nil {
return wrapErrorInTx(errors.Wrapf(ErrSessionFinishedWithError, "wait until %v", cooldown))
}

err = r.CheckUserKYC(ctx, userID)
if err != nil {
return wrapErrorInTx(err)
}

quiz, err = r.startNewSession(ctx, userID, now, tx, lang, questions)

return wrapErrorInTx(err)
})

return quiz, err
}

func calculateProgress(correctAnswers, currentAnswers []uint8) (correctNum, incorrectNum uint8) {
correct := correctAnswers
if len(currentAnswers) < len(correctAnswers) {
Expand Down Expand Up @@ -412,7 +476,7 @@ group by

deadline := data.StartedAt.Add(stdlibtime.Duration(r.config.MaxSessionDurationSeconds) * stdlibtime.Second)
if deadline.Before(now) {
return userProgress{}, ErrSessionExpired
return userProgress{}, errSessionExpired
}

return data.userProgress, nil
Expand Down Expand Up @@ -555,7 +619,7 @@ func (r *repositoryImpl) modifyUser(ctx context.Context, success bool, now *time
return errors.Wrapf(r.Users.ModifyUser(ctx, usr, nil), "failed to modify user %#v", usr)
}

func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive //.
func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive,gocognit //.
ctx context.Context,
userID UserID,
question, answer uint8,
Expand All @@ -564,6 +628,11 @@ func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive //.
now := stdlibtime.Now().Truncate(stdlibtime.Second).UTC()
progress, pErr := r.CheckUserRunningSession(ctx, userID, now, tx)
if pErr != nil {
if errors.Is(pErr, errSessionExpired) {
quiz = &Quiz{Result: FailureResult}
pErr = r.UserMarkSessionAsFinished(ctx, userID, now, tx, false, false)
}

return wrapErrorInTx(pErr)
}
_, err = r.CheckQuestionNumber(ctx, progress.Lang, progress.Questions, question, tx)
Expand Down
20 changes: 10 additions & 10 deletions kyc/quiz/quiz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,8 @@ func testManagerSessionStart(ctx context.Context, t *testing.T, r *repositoryImp
t.Run("Expired", func(t *testing.T) {
helperForceResetSessionStartedAt(t, r, "bogus")
session, err := r.StartQuizSession(ctx, "bogus", "en")
require.NoError(t, err)
require.NotNil(t, session)
require.NotNil(t, session.Progress)
require.NotNil(t, session.Progress.ExpiresAt)
require.NotEmpty(t, session.Progress.NextQuestion)
require.Equal(t, uint8(3), session.Progress.MaxQuestions)
require.Equal(t, uint8(1), session.Progress.NextQuestion.Number)
require.ErrorIs(t, err, ErrSessionFinishedWithError)
require.Nil(t, session)
})
t.Run("CoolDown", func(t *testing.T) {
helperSessionReset(t, r, "bogus", true)
Expand Down Expand Up @@ -218,7 +213,7 @@ func testManagerSessionSkip(ctx context.Context, t *testing.T, r *repositoryImpl
helperForceResetSessionStartedAt(t, r, "bogus")

err = r.SkipQuizSession(ctx, "bogus")
require.ErrorIs(t, err, ErrSessionExpired)
require.NoError(t, err)
})
t.Run("Finished", func(t *testing.T) {
t.Run("Success", func(t *testing.T) {
Expand Down Expand Up @@ -280,8 +275,13 @@ func testManagerSessionContinueErrors(ctx context.Context, t *testing.T, r *repo
data, err := r.StartQuizSession(ctx, "bogus", "en")
require.NoError(t, err)
helperForceResetSessionStartedAt(t, r, "bogus")
_, err = r.ContinueQuizSession(ctx, "bogus", data.Progress.NextQuestion.Number, 1)
require.ErrorIs(t, err, ErrSessionExpired)
data, err = r.ContinueQuizSession(ctx, "bogus", data.Progress.NextQuestion.Number, 1)
require.NoError(t, err)
require.Nil(t, data.Progress)
require.Equal(t, FailureResult, data.Result)

_, err = r.StartQuizSession(ctx, "bogus", "en")
require.ErrorIs(t, err, ErrSessionFinishedWithError)
})

t.Run("UnknownQuestionNumber", func(t *testing.T) {
Expand Down
Loading