package classes import ( "context" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/models" "gorm.io/gorm" ) // contextCancelled returns a context that is already cancelled, so a query // through it fails. func contextCancelled(t *testing.T) (context.Context, context.CancelFunc) { t.Helper() ctx, cancel := context.WithCancel(t.Context()) cancel() return ctx, cancel } // TestTouchLastSeen pins the five-minute rule of users.last_seen on both // sides of the boundary, for an active and a deactivated user (D-17). func TestTouchLastSeen(t *testing.T) { ctx := t.Context() db := throttleDB(t) user := models.User{Email: "touch@example.test", IsActivated: true} gone := models.User{Email: "touch-gone@example.test", IsActivated: true, DeletedAt: gorm.DeletedAt{Time: time.Now(), Valid: true}} for _, u := range []*models.User{&user, &gone} { if err := db.Omit("Groups").Create(u).Error; err != nil { t.Fatal(err) } } read := func(id uint) *time.Time { t.Helper() var row struct{ LastSeen *time.Time } if err := db.Raw(`SELECT last_seen FROM users WHERE id = ?`, id).Scan(&row).Error; err != nil { t.Fatal(err) } return row.LastSeen } set := func(id uint, age time.Duration) time.Time { t.Helper() at := time.Now().Add(-age).Truncate(time.Microsecond) if err := db.Exec(`UPDATE users SET last_seen = ? WHERE id = ?`, at, id).Error; err != nil { t.Fatal(err) } return at } updatedAt := func(id uint) time.Time { t.Helper() var at time.Time if err := db.Raw(`SELECT updated_at FROM users WHERE id = ?`, id).Scan(&at).Error; err != nil { t.Fatal(err) } return at } for _, id := range []uint{user.ID, gone.ID} { if read(id) != nil { t.Fatalf("user %d starts with last_seen", id) } stamped := updatedAt(id) started := time.Now().Add(-time.Second) if err := TouchLastSeen(ctx, db, id); err != nil { t.Fatal(err) } first := read(id) if first == nil || first.Before(started) { t.Fatalf("user %d: a NULL last_seen was not stamped: %v", id, first) } if !updatedAt(id).Equal(stamped) { t.Fatalf("user %d: the stamp touched updated_at", id) } // Just inside the window: untouched. Just outside: stamped again. for _, tc := range []struct { age time.Duration stamped bool }{ {0, false}, {4*time.Minute + 55*time.Second, false}, {5*time.Minute + 5*time.Second, true}, {24 * time.Hour, true}, } { was := set(id, tc.age) if err := TouchLastSeen(ctx, db, id); err != nil { t.Fatal(err) } now := read(id) if now == nil || now.Equal(was) == tc.stamped { t.Fatalf("user %d, last seen %s ago: stamped = %v, want %v", id, tc.age, !now.Equal(was), tc.stamped) } } } // One user's stamp never moves another's. other := set(gone.ID, time.Hour) if err := TouchLastSeen(ctx, db, user.ID); err != nil { t.Fatal(err) } if got := read(gone.ID); got == nil || !got.Equal(other) { t.Fatal("a stamp moved another user's last_seen") } // An unknown id and the zero id are no error; a missing handle and a // failed write are. if err := TouchLastSeen(ctx, db, 987654); err != nil { t.Fatal(err) } if err := TouchLastSeen(ctx, db, 0); err != nil { t.Fatal(err) } if err := TouchLastSeen(ctx, nil, user.ID); err == nil { t.Fatal("TouchLastSeen without a handle") } cancelled, cancel := contextCancelled(t) defer cancel() set(user.ID, time.Hour) if err := TouchLastSeen(cancelled, db, user.ID); err == nil { t.Fatal("TouchLastSeen swallowed a failed write") } // The handle's own conditions do not leak into the update. if err := TouchLastSeen(ctx, db.Where("1 = 0"), user.ID); err != nil { t.Fatal(err) } if got := read(user.ID); got == nil || time.Since(*got) > time.Minute { t.Fatalf("a scoped handle kept the stamp from being written: %v", got) } }