Fix home regeneration (#6251)
* Fix regeneration marker not being removed after completion * Return HTTP 206 from /api/v1/timelines/home if regeneration in progress Prioritize RegenerationWorker by putting it into default queue * Display loading indicator and poll home timeline while it regenerates * Add graphic to regeneration message * Make "not found" indicator consistent with home regeneration
This commit is contained in:
		
							parent
							
								
									59797ee233
								
							
						
					
					
						commit
						7badad7797
					
				|  | @ -9,7 +9,11 @@ class Api::V1::Timelines::HomeController < Api::BaseController | ||||||
| 
 | 
 | ||||||
|   def show |   def show | ||||||
|     @statuses = load_statuses |     @statuses = load_statuses | ||||||
|     render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) | 
 | ||||||
|  |     render json: @statuses, | ||||||
|  |            each_serializer: REST::StatusSerializer, | ||||||
|  |            relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), | ||||||
|  |            status: regeneration_in_progress? ? 206 : 200 | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
|  | @ -57,4 +61,8 @@ class Api::V1::Timelines::HomeController < Api::BaseController | ||||||
|   def pagination_since_id |   def pagination_since_id | ||||||
|     @statuses.first.id |     @statuses.first.id | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   def regeneration_in_progress? | ||||||
|  |     Redis.current.exists("account:#{current_account.id}:regeneration") | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 24 KiB | 
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 11 KiB | 
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 8.3 KiB | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 19 KiB | 
|  | @ -19,13 +19,14 @@ export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; | ||||||
| 
 | 
 | ||||||
| export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; | export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; | ||||||
| 
 | 
 | ||||||
| export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { | export function refreshTimelineSuccess(timeline, statuses, skipLoading, next, partial) { | ||||||
|   return { |   return { | ||||||
|     type: TIMELINE_REFRESH_SUCCESS, |     type: TIMELINE_REFRESH_SUCCESS, | ||||||
|     timeline, |     timeline, | ||||||
|     statuses, |     statuses, | ||||||
|     skipLoading, |     skipLoading, | ||||||
|     next, |     next, | ||||||
|  |     partial, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -88,7 +89,7 @@ export function refreshTimeline(timelineId, path, params = {}) { | ||||||
|   return function (dispatch, getState) { |   return function (dispatch, getState) { | ||||||
|     const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); |     const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); | ||||||
| 
 | 
 | ||||||
|     if (timeline.get('isLoading') || timeline.get('online')) { |     if (timeline.get('isLoading') || (timeline.get('online') && !timeline.get('isPartial'))) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -104,8 +105,12 @@ export function refreshTimeline(timelineId, path, params = {}) { | ||||||
|     dispatch(refreshTimelineRequest(timelineId, skipLoading)); |     dispatch(refreshTimelineRequest(timelineId, skipLoading)); | ||||||
| 
 | 
 | ||||||
|     api(getState).get(path, { params }).then(response => { |     api(getState).get(path, { params }).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       if (response.status === 206) { | ||||||
|       dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null)); |         dispatch(refreshTimelineSuccess(timelineId, [], skipLoading, null, true)); | ||||||
|  |       } else { | ||||||
|  |         const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|  |         dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null, false)); | ||||||
|  |       } | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(refreshTimelineFail(timelineId, error, skipLoading)); |       dispatch(refreshTimelineFail(timelineId, error, skipLoading)); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  | @ -2,9 +2,14 @@ import React from 'react'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| 
 | 
 | ||||||
| const MissingIndicator = () => ( | const MissingIndicator = () => ( | ||||||
|   <div className='missing-indicator'> |   <div className='regeneration-indicator missing-indicator'> | ||||||
|     <div> |     <div> | ||||||
|       <FormattedMessage id='missing_indicator.label' defaultMessage='Not found' /> |       <div className='regeneration-indicator__figure' /> | ||||||
|  | 
 | ||||||
|  |       <div className='regeneration-indicator__label'> | ||||||
|  |         <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' /> | ||||||
|  |         <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' /> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ import PropTypes from 'prop-types'; | ||||||
| import StatusContainer from '../containers/status_container'; | import StatusContainer from '../containers/status_container'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import ScrollableList from './scrollable_list'; | import ScrollableList from './scrollable_list'; | ||||||
|  | import { FormattedMessage } from 'react-intl'; | ||||||
| 
 | 
 | ||||||
| export default class StatusList extends ImmutablePureComponent { | export default class StatusList extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|  | @ -16,6 +17,7 @@ export default class StatusList extends ImmutablePureComponent { | ||||||
|     trackScroll: PropTypes.bool, |     trackScroll: PropTypes.bool, | ||||||
|     shouldUpdateScroll: PropTypes.func, |     shouldUpdateScroll: PropTypes.func, | ||||||
|     isLoading: PropTypes.bool, |     isLoading: PropTypes.bool, | ||||||
|  |     isPartial: PropTypes.bool, | ||||||
|     hasMore: PropTypes.bool, |     hasMore: PropTypes.bool, | ||||||
|     prepend: PropTypes.node, |     prepend: PropTypes.node, | ||||||
|     emptyMessage: PropTypes.node, |     emptyMessage: PropTypes.node, | ||||||
|  | @ -48,8 +50,23 @@ export default class StatusList extends ImmutablePureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { statusIds, ...other } = this.props; |     const { statusIds, ...other }  = this.props; | ||||||
|     const { isLoading } = other; |     const { isLoading, isPartial } = other; | ||||||
|  | 
 | ||||||
|  |     if (isPartial) { | ||||||
|  |       return ( | ||||||
|  |         <div className='regeneration-indicator'> | ||||||
|  |           <div> | ||||||
|  |             <div className='regeneration-indicator__figure' /> | ||||||
|  | 
 | ||||||
|  |             <div className='regeneration-indicator__label'> | ||||||
|  |               <FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' /> | ||||||
|  |               <FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' /> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     const scrollableContent = (isLoading || statusIds.size > 0) ? ( |     const scrollableContent = (isLoading || statusIds.size > 0) ? ( | ||||||
|       statusIds.map((statusId) => ( |       statusIds.map((statusId) => ( | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import { expandHomeTimeline } from '../../actions/timelines'; | import { expandHomeTimeline, refreshHomeTimeline } from '../../actions/timelines'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import StatusListContainer from '../ui/containers/status_list_container'; | import StatusListContainer from '../ui/containers/status_list_container'; | ||||||
| import Column from '../../components/column'; | import Column from '../../components/column'; | ||||||
|  | @ -16,6 +16,7 @@ const messages = defineMessages({ | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|   hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, |   hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, | ||||||
|  |   isPartial: state.getIn(['timelines', 'home', 'isPartial'], false), | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| @connect(mapStateToProps) | @connect(mapStateToProps) | ||||||
|  | @ -26,6 +27,7 @@ export default class HomeTimeline extends React.PureComponent { | ||||||
|     dispatch: PropTypes.func.isRequired, |     dispatch: PropTypes.func.isRequired, | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|     hasUnread: PropTypes.bool, |     hasUnread: PropTypes.bool, | ||||||
|  |     isPartial: PropTypes.bool, | ||||||
|     columnId: PropTypes.string, |     columnId: PropTypes.string, | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
|  | @ -57,6 +59,39 @@ export default class HomeTimeline extends React.PureComponent { | ||||||
|     this.props.dispatch(expandHomeTimeline()); |     this.props.dispatch(expandHomeTimeline()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   componentDidMount () { | ||||||
|  |     this._checkIfReloadNeeded(false, this.props.isPartial); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentDidUpdate (prevProps) { | ||||||
|  |     this._checkIfReloadNeeded(prevProps.isPartial, this.props.isPartial); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentWillUnmount () { | ||||||
|  |     this._stopPolling(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   _checkIfReloadNeeded (wasPartial, isPartial) { | ||||||
|  |     const { dispatch } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (wasPartial === isPartial) { | ||||||
|  |       return; | ||||||
|  |     } else if (!wasPartial && isPartial) { | ||||||
|  |       this.polling = setInterval(() => { | ||||||
|  |         dispatch(refreshHomeTimeline()); | ||||||
|  |       }, 3000); | ||||||
|  |     } else if (wasPartial && !isPartial) { | ||||||
|  |       this._stopPolling(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   _stopPolling () { | ||||||
|  |     if (this.polling) { | ||||||
|  |       clearInterval(this.polling); | ||||||
|  |       this.polling = null; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { intl, hasUnread, columnId, multiColumn } = this.props; |     const { intl, hasUnread, columnId, multiColumn } = this.props; | ||||||
|     const pinned = !!columnId; |     const pinned = !!columnId; | ||||||
|  |  | ||||||
|  | @ -120,13 +120,17 @@ export default class ListTimeline extends React.PureComponent { | ||||||
|     if (typeof list === 'undefined') { |     if (typeof list === 'undefined') { | ||||||
|       return ( |       return ( | ||||||
|         <Column> |         <Column> | ||||||
|           <LoadingIndicator /> |           <div className='scrollable'> | ||||||
|  |             <LoadingIndicator /> | ||||||
|  |           </div> | ||||||
|         </Column> |         </Column> | ||||||
|       ); |       ); | ||||||
|     } else if (list === false) { |     } else if (list === false) { | ||||||
|       return ( |       return ( | ||||||
|         <Column> |         <Column> | ||||||
|           <MissingIndicator /> |           <div className='scrollable'> | ||||||
|  |             <MissingIndicator /> | ||||||
|  |           </div> | ||||||
|         </Column> |         </Column> | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -47,6 +47,7 @@ const makeMapStateToProps = () => { | ||||||
|   const mapStateToProps = (state, { timelineId }) => ({ |   const mapStateToProps = (state, { timelineId }) => ({ | ||||||
|     statusIds: getStatusIds(state, { type: timelineId }), |     statusIds: getStatusIds(state, { type: timelineId }), | ||||||
|     isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), |     isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), | ||||||
|  |     isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), | ||||||
|     hasMore: !!state.getIn(['timelines', timelineId, 'next']), |     hasMore: !!state.getIn(['timelines', timelineId, 'next']), | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -30,7 +30,7 @@ const initialTimeline = ImmutableMap({ | ||||||
|   items: ImmutableList(), |   items: ImmutableList(), | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const normalizeTimeline = (state, timeline, statuses, next) => { | const normalizeTimeline = (state, timeline, statuses, next, isPartial) => { | ||||||
|   const oldIds    = state.getIn([timeline, 'items'], ImmutableList()); |   const oldIds    = state.getIn([timeline, 'items'], ImmutableList()); | ||||||
|   const ids       = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId)); |   const ids       = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId)); | ||||||
|   const wasLoaded = state.getIn([timeline, 'loaded']); |   const wasLoaded = state.getIn([timeline, 'loaded']); | ||||||
|  | @ -41,6 +41,7 @@ const normalizeTimeline = (state, timeline, statuses, next) => { | ||||||
|     mMap.set('isLoading', false); |     mMap.set('isLoading', false); | ||||||
|     if (!hadNext) mMap.set('next', next); |     if (!hadNext) mMap.set('next', next); | ||||||
|     mMap.set('items', wasLoaded ? ids.concat(oldIds) : ids); |     mMap.set('items', wasLoaded ? ids.concat(oldIds) : ids); | ||||||
|  |     mMap.set('isPartial', isPartial); | ||||||
|   })); |   })); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -124,7 +125,7 @@ export default function timelines(state = initialState, action) { | ||||||
|   case TIMELINE_EXPAND_FAIL: |   case TIMELINE_EXPAND_FAIL: | ||||||
|     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); |     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); | ||||||
|   case TIMELINE_REFRESH_SUCCESS: |   case TIMELINE_REFRESH_SUCCESS: | ||||||
|     return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next); |     return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial); | ||||||
|   case TIMELINE_EXPAND_SUCCESS: |   case TIMELINE_EXPAND_SUCCESS: | ||||||
|     return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next); |     return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next); | ||||||
|   case TIMELINE_UPDATE: |   case TIMELINE_UPDATE: | ||||||
|  |  | ||||||
|  | @ -2303,7 +2303,7 @@ | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .missing-indicator { | .regeneration-indicator { | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   font-size: 16px; |   font-size: 16px; | ||||||
|   font-weight: 500; |   font-weight: 500; | ||||||
|  | @ -2314,11 +2314,46 @@ | ||||||
|   flex: 1 1 auto; |   flex: 1 1 auto; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   justify-content: center; |   justify-content: center; | ||||||
|  |   padding: 20px; | ||||||
| 
 | 
 | ||||||
|   & > div { |   & > div { | ||||||
|     background: url('../images/mastodon-not-found.png') no-repeat center -50px; |  | ||||||
|     padding-top: 210px; |  | ||||||
|     width: 100%; |     width: 100%; | ||||||
|  |     background: transparent; | ||||||
|  |     padding-top: 0; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &__figure { | ||||||
|  |     background: url('../images/elephant_ui_working.svg') no-repeat center 0; | ||||||
|  |     width: 100%; | ||||||
|  |     height: 160px; | ||||||
|  |     background-size: contain; | ||||||
|  |     position: absolute; | ||||||
|  |     top: 50%; | ||||||
|  |     left: 50%; | ||||||
|  |     transform: translate(-50%, -50%); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &.missing-indicator { | ||||||
|  |     padding-top: 20px + 48px; | ||||||
|  | 
 | ||||||
|  |     .regeneration-indicator__figure { | ||||||
|  |       background-image: url('../images/elephant_ui_disappointed.svg'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &__label { | ||||||
|  |     margin-top: 200px; | ||||||
|  | 
 | ||||||
|  |     strong { | ||||||
|  |       display: block; | ||||||
|  |       margin-bottom: 10px; | ||||||
|  |       color: lighten($ui-base-color, 34%); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     span { | ||||||
|  |       font-size: 15px; | ||||||
|  |       font-weight: 400; | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -2749,7 +2784,6 @@ | ||||||
| @keyframes heartbeat { | @keyframes heartbeat { | ||||||
|   from { |   from { | ||||||
|     transform: scale(1); |     transform: scale(1); | ||||||
|     transform-origin: center center; |  | ||||||
|     animation-timing-function: ease-out; |     animation-timing-function: ease-out; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -2775,6 +2809,7 @@ | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .pulse-loading { | .pulse-loading { | ||||||
|  |   transform-origin: center center; | ||||||
|   animation: heartbeat 1.5s ease-in-out infinite both; |   animation: heartbeat 1.5s ease-in-out infinite both; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -3,5 +3,6 @@ | ||||||
| class PrecomputeFeedService < BaseService | class PrecomputeFeedService < BaseService | ||||||
|   def call(account) |   def call(account) | ||||||
|     FeedManager.instance.populate_feed(account) |     FeedManager.instance.populate_feed(account) | ||||||
|  |     Redis.current.del("account:#{account.id}:regeneration") | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -3,7 +3,7 @@ | ||||||
| class RegenerationWorker | class RegenerationWorker | ||||||
|   include Sidekiq::Worker |   include Sidekiq::Worker | ||||||
| 
 | 
 | ||||||
|   sidekiq_options queue: 'pull', backtrace: true, unique: :until_executed |   sidekiq_options unique: :until_executed | ||||||
| 
 | 
 | ||||||
|   def perform(account_id, _ = :home) |   def perform(account_id, _ = :home) | ||||||
|     account = Account.find(account_id) |     account = Account.find(account_id) | ||||||
|  |  | ||||||
|  | @ -43,15 +43,39 @@ describe ApplicationController, type: :controller do | ||||||
|       expect_updated_sign_in_at(user) |       expect_updated_sign_in_at(user) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     it 'regenerates feed when sign in is older than two weeks' do |     describe 'feed regeneration' do | ||||||
|       allow(RegenerationWorker).to receive(:perform_async) |       before do | ||||||
|       user.update(current_sign_in_at: 3.weeks.ago) |         alice = Fabricate(:account) | ||||||
|       sign_in user, scope: :user |         bob   = Fabricate(:account) | ||||||
|       get :show |  | ||||||
| 
 | 
 | ||||||
|       expect_updated_sign_in_at(user) |         user.account.follow!(alice) | ||||||
|       expect(Redis.current.get("account:#{user.account_id}:regeneration")).to eq 'true' |         user.account.follow!(bob) | ||||||
|       expect(RegenerationWorker).to have_received(:perform_async) | 
 | ||||||
|  |         Fabricate(:status, account: alice, text: 'hello world') | ||||||
|  |         Fabricate(:status, account: bob, text: 'yes hello') | ||||||
|  |         Fabricate(:status, account: user.account, text: 'test') | ||||||
|  | 
 | ||||||
|  |         user.update(last_sign_in_at: 'Tue, 04 Jul 2017 14:45:56 UTC +00:00', current_sign_in_at: 'Wed, 05 Jul 2017 22:10:52 UTC +00:00') | ||||||
|  | 
 | ||||||
|  |         sign_in user, scope: :user | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'sets a regeneration marker while regenerating' do | ||||||
|  |         allow(RegenerationWorker).to receive(:perform_async) | ||||||
|  |         get :show | ||||||
|  | 
 | ||||||
|  |         expect_updated_sign_in_at(user) | ||||||
|  |         expect(Redis.current.get("account:#{user.account_id}:regeneration")).to eq 'true' | ||||||
|  |         expect(RegenerationWorker).to have_received(:perform_async) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'regenerates feed when sign in is older than two weeks' do | ||||||
|  |         get :show | ||||||
|  | 
 | ||||||
|  |         expect_updated_sign_in_at(user) | ||||||
|  |         expect(Redis.current.zcard(FeedManager.instance.key(:home, user.account_id))).to eq 3 | ||||||
|  |         expect(Redis.current.get("account:#{user.account_id}:regeneration")).to be_nil | ||||||
|  |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def expect_updated_sign_in_at(user) |     def expect_updated_sign_in_at(user) | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue