diff --git a/graph/ent.resolvers.go b/graph/ent.resolvers.go index 9be0531..afbe3ef 100644 --- a/graph/ent.resolvers.go +++ b/graph/ent.resolvers.go @@ -94,7 +94,9 @@ func (r *Resolver) Question() QuestionResolver { return &questionResolver{r} } // User returns UserResolver implementation. func (r *Resolver) User() UserResolver { return &userResolver{r} } -type databaseResolver struct{ *Resolver } -type queryResolver struct{ *Resolver } -type questionResolver struct{ *Resolver } -type userResolver struct{ *Resolver } +type ( + databaseResolver struct{ *Resolver } + queryResolver struct{ *Resolver } + questionResolver struct{ *Resolver } + userResolver struct{ *Resolver } +) diff --git a/httpapi/auth/revoke.go b/httpapi/auth/revoke.go index e757263..6051c73 100644 --- a/httpapi/auth/revoke.go +++ b/httpapi/auth/revoke.go @@ -34,7 +34,7 @@ func (s *AuthService) RevokeToken(c *gin.Context) { } // Attempt to revoke the token - err := s.storage.Delete(c.Request.Context(), token) + err := s.useraccount.RevokeToken(c.Request.Context(), token) if err != nil && !errors.Is(err, auth.ErrNotFound) { // Internal server error - failed to revoke token c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/internal/events/README.md b/internal/events/README.md index 676197e..ff03d85 100644 --- a/internal/events/README.md +++ b/internal/events/README.md @@ -1,3 +1,69 @@ # Events 負責觸發事件和加減點數的 service。 + +## 事件表 + +### 憑證管理 + +- `login`:登入帳號 +- `impersonated`:管理員嘗試取得登入憑證 +- `logout`:登出帳號 +- `logout_all`:撤銷這個使用者的所有登入憑證 + +### 作答管理 + +- `submit_answer`:提交答案 + +## 點數發放規則 + +### 登入相關 + +#### 每日登入 (Daily Login) + +- **點數**: 20 點 +- **條件**: 使用者每天第一次登入時獲得 +- **描述**: `"daily login"` + +#### 每週登入 (Weekly Login) + +- **點數**: 50 點 +- **條件**: 使用者連續 7 天每天都登入時獲得 +- **描述**: `"weekly login"` + +### 作答相關 + +#### 首次嘗試 (First Attempt) + +- **點數**: 30 點 +- **條件**: 使用者第一次嘗試某個問題時獲得(無論答案是否正確) +- **描述**: `"first attempt on question {question_id}"` + +#### 每日嘗試 (Daily Attempt) + +- **點數**: 30 點 +- **條件**: 使用者每天第一次提交答案時獲得(無論答案是否正確) +- **描述**: `"daily attempt"` + +#### 正確答案 (Correct Answer) + +- **點數**: 60 點 +- **條件**: 使用者第一次答對某個問題時獲得 +- **描述**: `"correct answer on question {question_id}"` + +#### 第一名 (First Place) + +- **點數**: 80 點 +- **條件**: 使用者是第一個答對某個問題的人 +- **描述**: `"first place on question {question_id}"` + +### 點數累計規則 + +當使用者提交答案時,系統會依序檢查並發放以下點數: + +1. **首次嘗試點數** - 如果是第一次嘗試該問題 +2. **每日嘗試點數** - 如果是當天第一次提交答案 +3. **正確答案點數** - 如果答案正確且是第一次答對該問題 +4. **第一名點數** - 如果答案正確且是所有使用者中第一個答對該問題 + +單次提交最多可獲得:30 (首次嘗試) + 30 (每日嘗試) + 60 (正確答案) + 80 (第一名) = **200 點** diff --git a/internal/events/constants.go b/internal/events/constants.go index 7698fd2..9acde6b 100644 --- a/internal/events/constants.go +++ b/internal/events/constants.go @@ -5,6 +5,8 @@ type EventType string const ( EventTypeLogin EventType = "login" EventTypeImpersonated EventType = "impersonated" + EventTypeLogout EventType = "logout" + EventTypeLogoutAll EventType = "logout_all" EventTypeSubmitAnswer EventType = "submit_answer" ) diff --git a/internal/events/points.go b/internal/events/points.go index 2996c30..459c1e9 100644 --- a/internal/events/points.go +++ b/internal/events/points.go @@ -2,23 +2,45 @@ package events import ( "context" + "fmt" "log/slog" "time" "github.com/database-playground/backend-v2/ent" "github.com/database-playground/backend-v2/ent/event" "github.com/database-playground/backend-v2/ent/point" + "github.com/database-playground/backend-v2/ent/question" + "github.com/database-playground/backend-v2/ent/submission" "github.com/database-playground/backend-v2/ent/user" ) +// startOfDay returns the start of the given day (midnight). +func startOfDay(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + +// startOfToday returns the start of today (midnight). +func startOfToday() time.Time { + return startOfDay(time.Now()) +} + const ( - PointDescriptionDailyLogin = "daily login" - PointDescriptionWeeklyLogin = "weekly login" + PointDescriptionDailyLogin = "daily login" + PointDescriptionWeeklyLogin = "weekly login" + PointDescriptionFirstAttempt = "first attempt on question %d" + PointDescriptionDailyAttempt = "daily attempt" + PointDescriptionCorrectAnswer = "correct answer on question %d" + PointDescriptionFirstPlace = "first place on question %d" ) const ( - PointValueDailyLogin = 20 - PointValueWeeklyLogin = 50 + PointValueDailyLogin = 20 + PointValueWeeklyLogin = 50 + PointValueFirstAttempt = 30 + PointValueDailyAttempt = 30 + PointValueCorrectAnswer = 60 + PointValueFirstPlace = 80 ) // PointsGranter determines if the criteria is met to grant points to a user. @@ -42,17 +64,92 @@ func (d *PointsGranter) HandleEvent(ctx context.Context, event *ent.Event) error slog.Info("granted daily login points", "user_id", event.UserID) } return err + case string(EventTypeSubmitAnswer): + return d.handleSubmitAnswerEvent(ctx, event) } return nil } +// handleSubmitAnswerEvent handles the submit answer event and grants appropriate points. +func (d *PointsGranter) handleSubmitAnswerEvent(ctx context.Context, event *ent.Event) error { + // Extract submission_id and question_id from payload + var submissionID int + switch v := event.Payload["submission_id"].(type) { + case float64: + submissionID = int(v) + case int: + submissionID = v + default: + return fmt.Errorf("submission_id not found in payload or has invalid type") + } + + var questionID int + switch v := event.Payload["question_id"].(type) { + case float64: + questionID = int(v) + case int: + questionID = v + default: + return fmt.Errorf("question_id not found in payload or has invalid type") + } + + // Get the submission to check its status + sub, err := d.entClient.Submission.Get(ctx, submissionID) + if err != nil { + return fmt.Errorf("get submission: %w", err) + } + + // Grant first attempt points (regardless of correctness) + ok, err := d.GrantFirstAttemptPoints(ctx, event.UserID, questionID) + if err != nil { + return fmt.Errorf("grant first attempt points: %w", err) + } + if ok { + slog.Info("granted first attempt points", "user_id", event.UserID, "question_id", questionID) + } + + // Grant daily attempt points + ok, err = d.GrantDailyAttemptPoints(ctx, event.UserID) + if err != nil { + return fmt.Errorf("grant daily attempt points: %w", err) + } + if ok { + slog.Info("granted daily attempt points", "user_id", event.UserID) + } + + // If the submission is successful, grant correct answer and first place points + if sub.Status == submission.StatusSuccess { + // Grant correct answer points + ok, err = d.GrantCorrectAnswerPoints(ctx, event.UserID, questionID) + if err != nil { + return fmt.Errorf("grant correct answer points: %w", err) + } + if ok { + slog.Info("granted correct answer points", "user_id", event.UserID, "question_id", questionID) + } + + // Grant first place points + ok, err = d.GrantFirstPlacePoints(ctx, event.UserID, questionID) + if err != nil { + return fmt.Errorf("grant first place points: %w", err) + } + if ok { + slog.Info("granted first place points", "user_id", event.UserID, "question_id", questionID) + } + } + + return nil +} + // GrantDailyLoginPoints grants the "daily login" points to a user. func (d *PointsGranter) GrantDailyLoginPoints(ctx context.Context, userID int) (bool, error) { + today := startOfToday() + // Check if we have granted the "daily login" points for this user today. hasPointsRecord, err := d.entClient.Point.Query(). Where(point.HasUserWith(user.ID(userID))). Where(point.DescriptionEQ(PointDescriptionDailyLogin)). - Where(point.GrantedAtGTE(time.Now().AddDate(0, 0, -1))).Exist(ctx) + Where(point.GrantedAtGTE(today)).Exist(ctx) if err != nil { return false, err } @@ -64,7 +161,7 @@ func (d *PointsGranter) GrantDailyLoginPoints(ctx context.Context, userID int) ( hasTodayLoginRecord, err := d.entClient.Event.Query(). Where(event.Type(string(EventTypeLogin))). Where(event.UserID(userID)). - Where(event.TriggeredAtGTE(time.Now().AddDate(0, 0, -1))). + Where(event.TriggeredAtGTE(today)). Exist(ctx) if err != nil { return false, err @@ -88,11 +185,14 @@ func (d *PointsGranter) GrantDailyLoginPoints(ctx context.Context, userID int) ( // GrantWeeklyLoginPoints grants the "weekly login" points to a user. func (d *PointsGranter) GrantWeeklyLoginPoints(ctx context.Context, userID int) (bool, error) { + // Calculate the start of 6 days ago (start of the 7-day period) + sevenDaysAgo := startOfDay(time.Now().AddDate(0, 0, -6)) + // Check if we have granted the "weekly login" points for this user this week. hasPointsRecord, err := d.entClient.Point.Query(). Where(point.HasUserWith(user.ID(userID))). Where(point.DescriptionEQ(PointDescriptionWeeklyLogin)). - Where(point.GrantedAtGTE(time.Now().AddDate(0, 0, -7))).Exist(ctx) + Where(point.GrantedAtGTE(sevenDaysAgo)).Exist(ctx) if err != nil { return false, err } @@ -100,23 +200,23 @@ func (d *PointsGranter) GrantWeeklyLoginPoints(ctx context.Context, userID int) return false, nil } - // Check if the user has logged in every day this week. + // Check if the user has logged in every day for the last 7 days. weekLoginRecords, err := d.entClient.Event.Query(). Where(event.Type(string(EventTypeLogin))). Where(event.UserID(userID)). - Where(event.TriggeredAtGTE(time.Now().AddDate(0, 0, -7))). + Where(event.TriggeredAtGTE(sevenDaysAgo)). All(ctx) if err != nil { return false, err } // Aggregated by day - weekLoginRecordsByDay := make(map[time.Time]int) + distinctLoginDays := make(map[time.Time]int) for _, record := range weekLoginRecords { - weekLoginRecordsByDay[record.TriggeredAt.Truncate(24*time.Hour)]++ + distinctLoginDays[startOfDay(record.TriggeredAt)]++ } - if len(weekLoginRecordsByDay) != 7 { + if len(distinctLoginDays) != 7 { return false, nil } @@ -131,3 +231,180 @@ func (d *PointsGranter) GrantWeeklyLoginPoints(ctx context.Context, userID int) } return true, nil } + +// GrantFirstAttemptPoints grants the "first attempt" points to a user. +// This is awarded when a user attempts a question for the first time. +func (d *PointsGranter) GrantFirstAttemptPoints(ctx context.Context, userID int, questionID int) (bool, error) { + // Check if we have granted the "first attempt" points for this user on this question. + description := fmt.Sprintf(PointDescriptionFirstAttempt, questionID) + hasPointsRecord, err := d.entClient.Point.Query(). + Where(point.HasUserWith(user.ID(userID))). + Where(point.DescriptionEQ(description)). + Exist(ctx) + if err != nil { + return false, err + } + if hasPointsRecord { + return false, nil + } + + // Check if this is the user's first submission for this question. + submissionCount, err := d.entClient.Submission.Query(). + Where(submission.HasUserWith(user.ID(userID))). + Where(submission.HasQuestionWith(question.IDEQ(questionID))). + Count(ctx) + if err != nil { + return false, err + } + if submissionCount != 1 { + return false, nil + } + + // Grant the "first attempt" points to the user. + err = d.entClient.Point.Create(). + SetUserID(userID). + SetDescription(description). + SetPoints(PointValueFirstAttempt). + Exec(ctx) + if err != nil { + return false, err + } + + return true, nil +} + +// GrantDailyAttemptPoints grants the "daily attempt" points to a user. +// This is awarded when a user attempts any question today. +func (d *PointsGranter) GrantDailyAttemptPoints(ctx context.Context, userID int) (bool, error) { + today := startOfToday() + + // Check if we have granted the "daily attempt" points for this user today. + hasPointsRecord, err := d.entClient.Point.Query(). + Where(point.HasUserWith(user.ID(userID))). + Where(point.DescriptionEQ(PointDescriptionDailyAttempt)). + Where(point.GrantedAtGTE(today)). + Exist(ctx) + if err != nil { + return false, err + } + if hasPointsRecord { + return false, nil + } + + // Check if the user has submitted any answer today. + hasSubmittedToday, err := d.entClient.Event.Query(). + Where(event.Type(string(EventTypeSubmitAnswer))). + Where(event.UserID(userID)). + Where(event.TriggeredAtGTE(today)). + Exist(ctx) + if err != nil { + return false, err + } + if !hasSubmittedToday { + return false, nil + } + + // Grant the "daily attempt" points to the user. + err = d.entClient.Point.Create(). + SetUserID(userID). + SetDescription(PointDescriptionDailyAttempt). + SetPoints(PointValueDailyAttempt). + Exec(ctx) + if err != nil { + return false, err + } + + return true, nil +} + +// GrantCorrectAnswerPoints grants the "correct answer" points to a user. +// This is awarded when a user answers a question correctly for the first time. +func (d *PointsGranter) GrantCorrectAnswerPoints(ctx context.Context, userID int, questionID int) (bool, error) { + // Check if we have granted the "correct answer" points for this user on this question. + description := fmt.Sprintf(PointDescriptionCorrectAnswer, questionID) + hasPointsRecord, err := d.entClient.Point.Query(). + Where(point.HasUserWith(user.ID(userID))). + Where(point.DescriptionEQ(description)). + Exist(ctx) + if err != nil { + return false, err + } + if hasPointsRecord { + return false, nil + } + + // Check if the user has a successful submission for this question. + hasSuccessfulSubmission, err := d.entClient.Submission.Query(). + Where(submission.HasUserWith(user.ID(userID))). + Where(submission.HasQuestionWith(question.IDEQ(questionID))). + Where(submission.StatusEQ(submission.StatusSuccess)). + Exist(ctx) + if err != nil { + return false, err + } + if !hasSuccessfulSubmission { + return false, nil + } + + // Grant the "correct answer" points to the user. + err = d.entClient.Point.Create(). + SetUserID(userID). + SetDescription(description). + SetPoints(PointValueCorrectAnswer). + Exec(ctx) + if err != nil { + return false, err + } + + return true, nil +} + +// GrantFirstPlacePoints grants the "first place" points to a user. +// This is awarded when a user is the first to answer a question correctly. +func (d *PointsGranter) GrantFirstPlacePoints(ctx context.Context, userID int, questionID int) (bool, error) { + // Check if we have granted the "first place" points for any user on this question. + description := fmt.Sprintf(PointDescriptionFirstPlace, questionID) + hasPointsRecord, err := d.entClient.Point.Query(). + Where(point.DescriptionEQ(description)). + Exist(ctx) + if err != nil { + return false, err + } + if hasPointsRecord { + return false, nil + } + + // Get the first successful submission for this question. + firstSuccessfulSubmission, err := d.entClient.Submission.Query(). + Where(submission.HasQuestionWith(question.IDEQ(questionID))). + Where(submission.StatusEQ(submission.StatusSuccess)). + Order(submission.BySubmittedAt()). + First(ctx) + if err != nil { + if ent.IsNotFound(err) { + return false, nil + } + return false, err + } + + // Check if this submission belongs to the current user. + submitterID, err := firstSuccessfulSubmission.QueryUser().OnlyID(ctx) + if err != nil { + return false, err + } + if submitterID != userID { + return false, nil + } + + // Grant the "first place" points to the user. + err = d.entClient.Point.Create(). + SetUserID(userID). + SetDescription(description). + SetPoints(PointValueFirstPlace). + Exec(ctx) + if err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/events/points_test.go b/internal/events/points_test.go index aee7cc3..e09f97d 100644 --- a/internal/events/points_test.go +++ b/internal/events/points_test.go @@ -7,6 +7,7 @@ import ( "github.com/database-playground/backend-v2/ent" "github.com/database-playground/backend-v2/ent/point" + "github.com/database-playground/backend-v2/ent/submission" "github.com/database-playground/backend-v2/ent/user" "github.com/database-playground/backend-v2/internal/events" "github.com/database-playground/backend-v2/internal/setup" @@ -414,3 +415,796 @@ func TestGrantWeeklyLoginPoints_NonExistentUser(t *testing.T) { require.NoError(t, err) require.False(t, granted) } + +// createQuestion creates a question for testing +func createQuestion(t *testing.T, client *ent.Client, databaseID int) int { + t.Helper() + + ctx := context.Background() + + q, err := client.Question.Create(). + SetCategory("test-category"). + SetTitle("Test Question"). + SetDescription("Test Description"). + SetReferenceAnswer("SELECT * FROM test;"). + SetDatabaseID(databaseID). + Save(ctx) + require.NoError(t, err) + + return q.ID +} + +// createDatabase creates a database for testing +func createDatabase(t *testing.T, client *ent.Client) int { + t.Helper() + + ctx := context.Background() + + db, err := client.Database.Create(). + SetSlug("test-db"). + SetDescription("Test Database"). + SetSchema("CREATE TABLE test (id INT);"). + SetRelationFigure("https://example.com/test-db-relation.png"). + Save(ctx) + require.NoError(t, err) + + return db.ID +} + +// createSubmission creates a submission for testing +func createSubmission(t *testing.T, client *ent.Client, userID int, questionID int, status submission.Status, submittedAt time.Time) int { + t.Helper() + + ctx := context.Background() + + sub, err := client.Submission.Create(). + SetUserID(userID). + SetQuestionID(questionID). + SetSubmittedCode("SELECT * FROM test;"). + SetStatus(status). + SetSubmittedAt(submittedAt). + Save(ctx) + require.NoError(t, err) + + return sub.ID +} + +// createSubmitAnswerEvent creates a submit answer event for testing +func createSubmitAnswerEvent(t *testing.T, client *ent.Client, userID int, submissionID int, questionID int, triggeredAt time.Time) { + t.Helper() + + ctx := context.Background() + + _, err := client.Event.Create(). + SetUserID(userID). + SetType(string(events.EventTypeSubmitAnswer)). + SetPayload(map[string]any{ + // ent requires float64 for JSON serialization + "submission_id": float64(submissionID), + "question_id": float64(questionID), + }). + SetTriggeredAt(triggeredAt). + Save(ctx) + require.NoError(t, err) +} + +func TestGrantFirstAttemptPoints_Success(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + // Create a database and question + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // Create the first submission for this user on this question + createSubmission(t, client, userID, questionID, submission.StatusFailed, now) + + // Grant first attempt points + granted, err := granter.GrantFirstAttemptPoints(ctx, userID, questionID) + require.NoError(t, err) + require.True(t, granted) + + // Verify points were created + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 1) + require.Equal(t, events.PointValueFirstAttempt, pointsRecords[0].Points) +} + +func TestGrantFirstAttemptPoints_AlreadyGranted(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // Create a submission + createSubmission(t, client, userID, questionID, submission.StatusFailed, now) + + // Grant points once + granted, err := granter.GrantFirstAttemptPoints(ctx, userID, questionID) + require.NoError(t, err) + require.True(t, granted) + + // Attempt to grant again + granted, err = granter.GrantFirstAttemptPoints(ctx, userID, questionID) + require.NoError(t, err) + require.False(t, granted) + + // Verify only one points record exists + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 1) +} + +func TestGrantFirstAttemptPoints_SecondSubmission(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // Create two submissions + createSubmission(t, client, userID, questionID, submission.StatusFailed, now) + createSubmission(t, client, userID, questionID, submission.StatusSuccess, now.Add(time.Minute)) + + // Attempt to grant first attempt points (should fail because there are 2 submissions) + granted, err := granter.GrantFirstAttemptPoints(ctx, userID, questionID) + require.NoError(t, err) + require.False(t, granted) + + // Verify no points record was created + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 0) +} + +func TestGrantDailyAttemptPoints_Success(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + submissionID := createSubmission(t, client, userID, questionID, submission.StatusFailed, now) + + // Create a submit answer event from today + createSubmitAnswerEvent(t, client, userID, submissionID, questionID, now) + + // Grant daily attempt points + granted, err := granter.GrantDailyAttemptPoints(ctx, userID) + require.NoError(t, err) + require.True(t, granted) + + // Verify points were created + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + Where(point.DescriptionEQ(events.PointDescriptionDailyAttempt)). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 1) + require.Equal(t, events.PointValueDailyAttempt, pointsRecords[0].Points) +} + +func TestGrantDailyAttemptPoints_AlreadyGrantedToday(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + submissionID := createSubmission(t, client, userID, questionID, submission.StatusFailed, now) + + // Create a submit answer event from today + createSubmitAnswerEvent(t, client, userID, submissionID, questionID, now) + + // Create an existing points record from today + createPointsRecord(t, client, userID, events.PointDescriptionDailyAttempt, events.PointValueDailyAttempt, now) + + // Attempt to grant daily attempt points again + granted, err := granter.GrantDailyAttemptPoints(ctx, userID) + require.NoError(t, err) + require.False(t, granted) + + // Verify only one points record exists + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + Where(point.DescriptionEQ(events.PointDescriptionDailyAttempt)). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 1) +} + +func TestGrantDailyAttemptPoints_NoSubmissionToday(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + twoDaysAgo := time.Now().AddDate(0, 0, -2) + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + submissionID := createSubmission(t, client, userID, questionID, submission.StatusFailed, twoDaysAgo) + + // Create a submit answer event from 2 days ago + createSubmitAnswerEvent(t, client, userID, submissionID, questionID, twoDaysAgo) + + // Attempt to grant daily attempt points + granted, err := granter.GrantDailyAttemptPoints(ctx, userID) + require.NoError(t, err) + require.False(t, granted) + + // Verify no points record was created + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + Where(point.DescriptionEQ(events.PointDescriptionDailyAttempt)). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 0) +} + +func TestGrantCorrectAnswerPoints_Success(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // Create a successful submission + createSubmission(t, client, userID, questionID, submission.StatusSuccess, now) + + // Grant correct answer points + granted, err := granter.GrantCorrectAnswerPoints(ctx, userID, questionID) + require.NoError(t, err) + require.True(t, granted) + + // Verify points were created + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 1) + require.Equal(t, events.PointValueCorrectAnswer, pointsRecords[0].Points) +} + +func TestGrantCorrectAnswerPoints_AlreadyGranted(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // Create a successful submission + createSubmission(t, client, userID, questionID, submission.StatusSuccess, now) + + // Grant points once + granted, err := granter.GrantCorrectAnswerPoints(ctx, userID, questionID) + require.NoError(t, err) + require.True(t, granted) + + // Attempt to grant again + granted, err = granter.GrantCorrectAnswerPoints(ctx, userID, questionID) + require.NoError(t, err) + require.False(t, granted) + + // Verify only one points record exists + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 1) +} + +func TestGrantCorrectAnswerPoints_NoSuccessfulSubmission(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // Create a failed submission + createSubmission(t, client, userID, questionID, submission.StatusFailed, now) + + // Attempt to grant correct answer points + granted, err := granter.GrantCorrectAnswerPoints(ctx, userID, questionID) + require.NoError(t, err) + require.False(t, granted) + + // Verify no points record was created + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 0) +} + +func TestGrantFirstPlacePoints_Success(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // Create the first successful submission + createSubmission(t, client, userID, questionID, submission.StatusSuccess, now) + + // Grant first place points + granted, err := granter.GrantFirstPlacePoints(ctx, userID, questionID) + require.NoError(t, err) + require.True(t, granted) + + // Verify points were created + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 1) + require.Equal(t, events.PointValueFirstPlace, pointsRecords[0].Points) +} + +func TestGrantFirstPlacePoints_NotFirstPlace(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + + ctx := context.Background() + now := time.Now() + + // Create two users + userID1 := setupTestData(t, client) + setupResult, err := setup.Setup(ctx, client) + require.NoError(t, err) + + user2, err := client.User.Create(). + SetName("Test User 2"). + SetEmail("test2@example.com"). + SetGroup(setupResult.NewUserGroup). + Save(ctx) + require.NoError(t, err) + userID2 := user2.ID + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // User 1 submits successfully first + createSubmission(t, client, userID1, questionID, submission.StatusSuccess, now) + + // User 2 submits successfully later + createSubmission(t, client, userID2, questionID, submission.StatusSuccess, now.Add(time.Minute)) + + // Attempt to grant first place points to user 2 + granted, err := granter.GrantFirstPlacePoints(ctx, userID2, questionID) + require.NoError(t, err) + require.False(t, granted) + + // Verify no points record was created for user 2 + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID2))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 0) +} + +func TestGrantFirstPlacePoints_AlreadyGranted(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // Create the first successful submission + createSubmission(t, client, userID, questionID, submission.StatusSuccess, now) + + // Grant first place points once + granted, err := granter.GrantFirstPlacePoints(ctx, userID, questionID) + require.NoError(t, err) + require.True(t, granted) + + // Attempt to grant again + granted, err = granter.GrantFirstPlacePoints(ctx, userID, questionID) + require.NoError(t, err) + require.False(t, granted) + + // Verify only one points record exists + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 1) +} + +func TestHandleSubmitAnswerEvent_SuccessfulSubmission(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // Create the first successful submission + submissionID := createSubmission(t, client, userID, questionID, submission.StatusSuccess, now) + + // Create and handle the event + event, err := client.Event.Create(). + SetUserID(userID). + SetType(string(events.EventTypeSubmitAnswer)). + SetPayload(map[string]any{ + "submission_id": float64(submissionID), + "question_id": float64(questionID), + }). + SetTriggeredAt(now). + Save(ctx) + require.NoError(t, err) + + err = granter.HandleEvent(ctx, event) + require.NoError(t, err) + + // Verify all appropriate points were granted + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 4) + + totalPoints := 0 + for _, p := range pointsRecords { + totalPoints += p.Points + } + expectedTotal := events.PointValueFirstAttempt + events.PointValueDailyAttempt + events.PointValueCorrectAnswer + events.PointValueFirstPlace + require.Equal(t, expectedTotal, totalPoints) +} + +func TestHandleSubmitAnswerEvent_FailedSubmission(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // Create a failed submission + submissionID := createSubmission(t, client, userID, questionID, submission.StatusFailed, now) + + // Create and handle the event + event, err := client.Event.Create(). + SetUserID(userID). + SetType(string(events.EventTypeSubmitAnswer)). + SetPayload(map[string]any{ + "submission_id": float64(submissionID), + "question_id": float64(questionID), + }). + SetTriggeredAt(now). + Save(ctx) + require.NoError(t, err) + + err = granter.HandleEvent(ctx, event) + require.NoError(t, err) + + // Verify only first attempt and daily attempt points were granted + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 2) + + totalPoints := 0 + for _, p := range pointsRecords { + totalPoints += p.Points + } + expectedTotal := events.PointValueFirstAttempt + events.PointValueDailyAttempt + require.Equal(t, expectedTotal, totalPoints) +} + +func TestHandleSubmitAnswerEvent_SecondAttemptSuccess(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // First attempt - failed + submission1ID := createSubmission(t, client, userID, questionID, submission.StatusFailed, now) + event1, err := client.Event.Create(). + SetUserID(userID). + SetType(string(events.EventTypeSubmitAnswer)). + SetPayload(map[string]any{ + "submission_id": float64(submission1ID), + "question_id": float64(questionID), + }). + SetTriggeredAt(now). + Save(ctx) + require.NoError(t, err) + + err = granter.HandleEvent(ctx, event1) + require.NoError(t, err) + + // Second attempt - success + submission2ID := createSubmission(t, client, userID, questionID, submission.StatusSuccess, now.Add(time.Minute)) + event2, err := client.Event.Create(). + SetUserID(userID). + SetType(string(events.EventTypeSubmitAnswer)). + SetPayload(map[string]any{ + "submission_id": float64(submission2ID), + "question_id": float64(questionID), + }). + SetTriggeredAt(now.Add(time.Minute)). + Save(ctx) + require.NoError(t, err) + + err = granter.HandleEvent(ctx, event2) + require.NoError(t, err) + + // Verify points were granted correctly + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 4) + + totalPoints := 0 + for _, p := range pointsRecords { + totalPoints += p.Points + } + expectedTotal := events.PointValueFirstAttempt + events.PointValueDailyAttempt + events.PointValueCorrectAnswer + events.PointValueFirstPlace + require.Equal(t, expectedTotal, totalPoints) +} + +// TestGrantDailyLoginPoints_MidnightBoundary tests the edge case where events happen around midnight +func TestGrantDailyLoginPoints_MidnightBoundary(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + // Get today's date at 11:59 PM (just before midnight) + year, month, day := now.Date() + yesterdayNight := time.Date(year, month, day-1, 23, 59, 59, 0, now.Location()) + + // Create a login event from yesterday at 11:59 PM + createLoginEvent(t, client, userID, yesterdayNight) + + // Create a points record from yesterday + createPointsRecord(t, client, userID, events.PointDescriptionDailyLogin, events.PointValueDailyLogin, yesterdayNight) + + // Now create a login event from today at 12:01 AM (just after midnight) + todayMorning := time.Date(year, month, day, 0, 1, 0, 0, now.Location()) + createLoginEvent(t, client, userID, todayMorning) + + // Grant daily login points should succeed because the old record is from yesterday + granted, err := granter.GrantDailyLoginPoints(ctx, userID) + require.NoError(t, err) + require.True(t, granted, "Should grant points because yesterday's 11:59 PM and today's 12:01 AM are different days") + + // Verify two points records exist now (one from yesterday, one from today) + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + Where(point.DescriptionEQ(events.PointDescriptionDailyLogin)). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 2, "Should have two records: one from yesterday and one from today") +} + +// TestGrantDailyLoginPoints_SameDayDifferentTimes tests that multiple events on the same day only grant points once +func TestGrantDailyLoginPoints_SameDayDifferentTimes(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + // Get today's date + year, month, day := now.Date() + + // Create a login event at 8:00 AM today + morning := time.Date(year, month, day, 8, 0, 0, 0, now.Location()) + createLoginEvent(t, client, userID, morning) + + // Grant daily login points + granted, err := granter.GrantDailyLoginPoints(ctx, userID) + require.NoError(t, err) + require.True(t, granted) + + // Create another login event at 11:00 PM today (same day) + evening := time.Date(year, month, day, 23, 0, 0, 0, now.Location()) + createLoginEvent(t, client, userID, evening) + + // Attempt to grant daily login points again + granted, err = granter.GrantDailyLoginPoints(ctx, userID) + require.NoError(t, err) + require.False(t, granted, "Should not grant points again on the same day") + + // Verify only one points record exists + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + Where(point.DescriptionEQ(events.PointDescriptionDailyLogin)). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 1) +} + +// TestGrantDailyAttemptPoints_MidnightBoundary tests the edge case for daily attempts around midnight +func TestGrantDailyAttemptPoints_MidnightBoundary(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + databaseID := createDatabase(t, client) + questionID := createQuestion(t, client, databaseID) + + // Get today's date + year, month, day := now.Date() + + // Create a submission and event from yesterday at 11:59 PM + yesterdayNight := time.Date(year, month, day-1, 23, 59, 59, 0, now.Location()) + submissionID1 := createSubmission(t, client, userID, questionID, submission.StatusFailed, yesterdayNight) + createSubmitAnswerEvent(t, client, userID, submissionID1, questionID, yesterdayNight) + + // Create a points record from yesterday + createPointsRecord(t, client, userID, events.PointDescriptionDailyAttempt, events.PointValueDailyAttempt, yesterdayNight) + + // Create a submission and event from today at 12:01 AM + todayMorning := time.Date(year, month, day, 0, 1, 0, 0, now.Location()) + submissionID2 := createSubmission(t, client, userID, questionID, submission.StatusFailed, todayMorning) + createSubmitAnswerEvent(t, client, userID, submissionID2, questionID, todayMorning) + + // Grant daily attempt points should succeed because the old record is from yesterday + granted, err := granter.GrantDailyAttemptPoints(ctx, userID) + require.NoError(t, err) + require.True(t, granted, "Should grant points because yesterday's 11:59 PM and today's 12:01 AM are different days") + + // Verify two points records exist now + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + Where(point.DescriptionEQ(events.PointDescriptionDailyAttempt)). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 2, "Should have two records: one from yesterday and one from today") +} + +// TestGrantWeeklyLoginPoints_MidnightBoundary tests that weekly login properly counts distinct calendar days +func TestGrantWeeklyLoginPoints_MidnightBoundary(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + // Get today's date + year, month, day := now.Date() + + // Create login events for 7 consecutive days, but with times near midnight + // Day 0 (today): 00:01 AM + createLoginEvent(t, client, userID, time.Date(year, month, day, 0, 1, 0, 0, now.Location())) + + // Day -1: 23:59 PM + createLoginEvent(t, client, userID, time.Date(year, month, day-1, 23, 59, 0, 0, now.Location())) + + // Day -2: 00:30 AM + createLoginEvent(t, client, userID, time.Date(year, month, day-2, 0, 30, 0, 0, now.Location())) + + // Day -3: 23:30 PM + createLoginEvent(t, client, userID, time.Date(year, month, day-3, 23, 30, 0, 0, now.Location())) + + // Day -4: 01:00 AM + createLoginEvent(t, client, userID, time.Date(year, month, day-4, 1, 0, 0, 0, now.Location())) + + // Day -5: 22:00 PM + createLoginEvent(t, client, userID, time.Date(year, month, day-5, 22, 0, 0, 0, now.Location())) + + // Day -6: 02:00 AM + createLoginEvent(t, client, userID, time.Date(year, month, day-6, 2, 0, 0, 0, now.Location())) + + // Grant weekly login points + granted, err := granter.GrantWeeklyLoginPoints(ctx, userID) + require.NoError(t, err) + require.True(t, granted, "Should grant weekly points when user logged in on 7 distinct calendar days, regardless of time") + + // Verify points were created + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + Where(point.DescriptionEQ(events.PointDescriptionWeeklyLogin)). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 1) + require.Equal(t, events.PointValueWeeklyLogin, pointsRecords[0].Points) +} + +// TestGrantWeeklyLoginPoints_NotEnoughDistinctDays tests that multiple logins on the same day don't count as multiple days +func TestGrantWeeklyLoginPoints_NotEnoughDistinctDays(t *testing.T) { + client := testhelper.NewEntSqliteClient(t) + granter := events.NewPointsGranter(client) + userID := setupTestData(t, client) + + ctx := context.Background() + now := time.Now() + + // Get today's date + year, month, day := now.Date() + + // Create multiple login events on 6 days (not enough for weekly) + for i := 0; i < 6; i++ { + // Create 3 events per day at different times to test that multiple events on the same day only count once + createLoginEvent(t, client, userID, time.Date(year, month, day-i, 8, 0, 0, 0, now.Location())) + createLoginEvent(t, client, userID, time.Date(year, month, day-i, 16, 0, 0, 0, now.Location())) + createLoginEvent(t, client, userID, time.Date(year, month, day-i, 23, 59, 59, 0, now.Location())) + } + + // Also add multiple events just after midnight on day -5 (last day in our range) + // These should still count as the same day (day -5), not a new day + createLoginEvent(t, client, userID, time.Date(year, month, day-5, 0, 0, 1, 0, now.Location())) + createLoginEvent(t, client, userID, time.Date(year, month, day-5, 0, 30, 0, 0, now.Location())) + + // Attempt to grant weekly login points + granted, err := granter.GrantWeeklyLoginPoints(ctx, userID) + require.NoError(t, err) + require.False(t, granted, "Should not grant weekly points with only 6 distinct days (day-0 to day-5)") + + // Verify no points record was created + pointsRecords, err := client.Point.Query(). + Where(point.HasUserWith(user.IDEQ(userID))). + Where(point.DescriptionEQ(events.PointDescriptionWeeklyLogin)). + All(ctx) + require.NoError(t, err) + require.Len(t, pointsRecords, 0) +} diff --git a/internal/useraccount/token.go b/internal/useraccount/token.go index 5abf03b..723345b 100644 --- a/internal/useraccount/token.go +++ b/internal/useraccount/token.go @@ -91,10 +91,25 @@ func (c *Context) GrantToken(ctx context.Context, user *ent.User, machine string // RevokeToken revokes a token. func (c *Context) RevokeToken(ctx context.Context, token string) error { + tokenInfo, err := c.auth.Peek(ctx, token) + if err != nil { + return err + } + + c.eventService.TriggerEvent(ctx, events.Event{ + Type: events.EventTypeLogout, + UserID: tokenInfo.UserID, + }) + return c.auth.Delete(ctx, token) } // RevokeAllTokens revokes all tokens for a user. func (c *Context) RevokeAllTokens(ctx context.Context, userID int) error { + c.eventService.TriggerEvent(ctx, events.Event{ + Type: events.EventTypeLogoutAll, + UserID: userID, + }) + return c.auth.DeleteByUser(ctx, userID) } diff --git a/internal/useraccount/token_test.go b/internal/useraccount/token_test.go index 388c095..f5dfb1d 100644 --- a/internal/useraccount/token_test.go +++ b/internal/useraccount/token_test.go @@ -433,3 +433,109 @@ func TestGrantToken_MultipleTokensCreateMultipleEvents(t *testing.T) { assert.True(t, machines["machine-2"]) assert.True(t, machines["machine-3"]) } + +func TestRevokeToken_LogoutEventTriggered(t *testing.T) { + client := setupTestDatabase(t) + authStorage := newMockAuthStorage() + eventService := events_pkg.NewEventService(client) + ctx := useraccount.NewContext(client, authStorage, eventService) + context := context.Background() + + // Create a user in unverified group + unverifiedGroup, err := client.Group.Query().Where(group.NameEQ(useraccount.UnverifiedGroupSlug)).Only(context) + require.NoError(t, err) + + user, err := client.User.Create(). + SetName("Test User"). + SetEmail("test-event-logout@example.com"). + SetGroup(unverifiedGroup). + Save(context) + require.NoError(t, err) + + // Grant token first + token, err := ctx.GrantToken( + context, user, "test-machine-logout", + useraccount.WithFlow("login"), + ) + require.NoError(t, err) + require.NotEmpty(t, token) + + // Revoke token (should trigger logout event) + err = ctx.RevokeToken(context, token) + require.NoError(t, err) + + // Verify logout event was created in database + logoutEvents, err := client.Event.Query(). + Where(event.UserIDEQ(user.ID)). + Where(event.TypeEQ(string(events_pkg.EventTypeLogout))). + All(context) + require.NoError(t, err) + require.Len(t, logoutEvents, 1) + + // Verify event details + logoutEvent := logoutEvents[0] + assert.Equal(t, user.ID, logoutEvent.UserID) + assert.Equal(t, string(events_pkg.EventTypeLogout), logoutEvent.Type) + assert.NotZero(t, logoutEvent.TriggeredAt) +} + +func TestRevokeAllTokens_LogoutAllEventTriggered(t *testing.T) { + client := setupTestDatabase(t) + authStorage := newMockAuthStorage() + eventService := events_pkg.NewEventService(client) + ctx := useraccount.NewContext(client, authStorage, eventService) + context := context.Background() + + // Create a user in unverified group + unverifiedGroup, err := client.Group.Query().Where(group.NameEQ(useraccount.UnverifiedGroupSlug)).Only(context) + require.NoError(t, err) + + user, err := client.User.Create(). + SetName("Test User"). + SetEmail("test-event-logout-all@example.com"). + SetGroup(unverifiedGroup). + Save(context) + require.NoError(t, err) + + // Grant multiple tokens first + token1, err := ctx.GrantToken( + context, user, "test-machine-1", + useraccount.WithFlow("login"), + ) + require.NoError(t, err) + require.NotEmpty(t, token1) + + token2, err := ctx.GrantToken( + context, user, "test-machine-2", + useraccount.WithFlow("login"), + ) + require.NoError(t, err) + require.NotEmpty(t, token2) + + // Revoke all tokens (should trigger logout_all event) + err = ctx.RevokeAllTokens(context, user.ID) + require.NoError(t, err) + + // Verify logout_all event was created in database + logoutAllEvents, err := client.Event.Query(). + Where(event.UserIDEQ(user.ID)). + Where(event.TypeEQ(string(events_pkg.EventTypeLogoutAll))). + All(context) + require.NoError(t, err) + require.Len(t, logoutAllEvents, 1) + + // Verify event details + logoutAllEvent := logoutAllEvents[0] + assert.Equal(t, user.ID, logoutAllEvent.UserID) + assert.Equal(t, string(events_pkg.EventTypeLogoutAll), logoutAllEvent.Type) + assert.NotZero(t, logoutAllEvent.TriggeredAt) + + // Verify tokens are actually revoked + _, err = authStorage.Get(context, token1) + require.Error(t, err) + assert.Equal(t, auth.ErrNotFound, err) + + _, err = authStorage.Get(context, token2) + require.Error(t, err) + assert.Equal(t, auth.ErrNotFound, err) +}