Skip to content
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
34 changes: 29 additions & 5 deletions app/controllers/dashboard_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,41 @@ def faq; end
def about; end

def wall_of_fame
@coaches_count = WorkshopInvitation.to_coaches.attended.distinct.count(:member_id)
coaches = Member.where(id: top_coach_query
.year(year_param))
.includes(:skills)
@pagy, @coaches = pagy(coaches)
options = past_year? ? {} : { expires_in: 24.hours }
body = Rails.cache.fetch(wall_of_fame_cache_key, **options) { render_wall_of_fame_body }
# rubocop:disable Rails/OutputSafety
render html: body.html_safe, layout: 'application'
# rubocop:enable Rails/OutputSafety
end

def participant_guide; end

private

def past_year?
(2013...Time.zone.today.year).cover?(year_param)
end

def wall_of_fame_cache_key
# Match pagy's page coercion so the cache key and the rendered page always
# agree, and arbitrary strings cannot expand the key space.
page = [params[:page].to_s.to_i, 1].max
date_segment = past_year? ? nil : "#{Time.zone.today}/"
"coaches/wall_of_fame/v2/#{date_segment}#{year_param}/#{page}/#{I18n.locale}"
end

def render_wall_of_fame_body
@coaches_count = WorkshopInvitation.to_coaches.attended.distinct.count(:member_id)
coaches = Member.where(id: top_coach_query
.year(year_param))
.includes(:skills)
# pagy copies every request param into pagination links; keep only the
# year the links must preserve so the filling request's junk params are
# not frozen into the cached body.
@pagy, @coaches = pagy(coaches, querify: ->(params) { params.keep_if { |k, _| %w[year page].include?(k) } })
render_to_string(layout: false)
end

def year_param
params.permit(:year)[:year]&.to_i || Time.zone.today.year
end
Expand Down
108 changes: 108 additions & 0 deletions spec/controllers/dashboard_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,112 @@ def assigns(symbol)
expect(assigns(:coaches).first.name).to start_with('Coach')
end
end

describe 'GET #wall_of_fame caching' do
render_views

around do |example|
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
example.run
Rails.cache = original_cache
end

let!(:workshop) { Fabricate(:workshop, date_and_time: Time.zone.now) }

before do
Fabricate(:attended_coach,
member: Fabricate(:member, name: 'Cached', surname: 'Coach'),
workshop:)
end

it 'stores the current-year body for 24 hours under a date, year, page, and locale key' do
expected_key = "coaches/wall_of_fame/v2/#{Time.zone.today}/#{Time.zone.now.year}/1/en"

allow(Rails.cache).to receive(:fetch)
.with(expected_key, expires_in: 24.hours)
.and_call_original

get :wall_of_fame

expect(response.body).to include('Cached Coach')
expect(response.body).to include('<!DOCTYPE html')
expect(Rails.cache).to have_received(:fetch)
.with(expected_key, expires_in: 24.hours)
expect(Rails.cache.read(expected_key)).to be_present
end

it 'stores the past-year body without a date segment or explicit expiry' do
past_workshop = Fabricate(:workshop, date_and_time: Time.zone.local(2013, 6, 1))
Fabricate(:attended_coach,
member: Fabricate(:member, name: 'Cached', surname: 'Coach'),
workshop: past_workshop)
expected_key = 'coaches/wall_of_fame/v2/2013/1/en'

allow(Rails.cache).to receive(:fetch)
.with(expected_key)
.and_call_original

get :wall_of_fame, params: { year: 2013 }

expect(response.body).to include('Cached Coach')
expect(Rails.cache).to have_received(:fetch)
.with(expected_key)
expect(Rails.cache.read(expected_key)).to be_present
end

it 'serves junk years from a dated, expiring key' do
get :wall_of_fame, params: { year: 1999 }

base_key = "coaches/wall_of_fame/v2/#{Time.zone.today}"
expect(Rails.cache.read("#{base_key}/1999/1/en")).to be_present
expect(Rails.cache.read('coaches/wall_of_fame/v2/1999/1/en')).to be_nil
end

it 'serves the cached body without re-rendering from the database' do
get :wall_of_fame
expect(response.body).to include('Cached Coach')

WorkshopInvitation.where(member: Member.find_by(name: 'Cached')).destroy_all

get :wall_of_fame
expect(response.body).to include('Cached Coach')
end

it 'rotates the cache key with the year and page parameters' do
get :wall_of_fame
get :wall_of_fame, params: { year: 2013 }
get :wall_of_fame, params: { page: 2 }

expect(Rails.cache.read("coaches/wall_of_fame/v2/#{Time.zone.today}/#{Time.zone.now.year}/1/en")).to be_present
expect(Rails.cache.read('coaches/wall_of_fame/v2/2013/1/en')).to be_present
expect(Rails.cache.read("coaches/wall_of_fame/v2/#{Time.zone.today}/#{Time.zone.now.year}/2/en")).to be_present
end

it 'coerces non-numeric page values into the integer page for the cache key' do
get :wall_of_fame, params: { page: '3/de' }

base_key = "coaches/wall_of_fame/v2/#{Time.zone.today}"
expect(Rails.cache.read("#{base_key}/#{Time.zone.now.year}/3/en")).to be_present
# A page string cannot place one locale's body under another locale's key.
expect(Rails.cache.read("#{base_key}/#{Time.zone.now.year}/3/de")).to be_nil
end

it 'keeps unrelated query params out of the cached pagination links' do
21.times do |i|
Fabricate(:attended_coach,
member: Fabricate(:member, name: "Wall#{i}", surname: 'Coach'),
workshop:)
end

get :wall_of_fame, params: { fbclid: 'spam' }

# The layout's og:url mirrors the request URL and is never cached, so
# assert against the cached body itself.
base_key = "coaches/wall_of_fame/v2/#{Time.zone.today}"
cached = Rails.cache.read("#{base_key}/#{Time.zone.now.year}/1/en")
expect(cached).to include('page=2')
expect(cached).not_to include('fbclid')
end
end
end
Loading