Add follow up emails

This commit is contained in:
Eugene Burmakin 2025-09-05 19:39:50 +02:00
parent e3b2fcd415
commit 68a0a8f23c
19 changed files with 614 additions and 90 deletions

View file

@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
# [UNRELEASED]
## Changed
- Stats page now loads significantly faster due to caching
- Data on the Stats page is being updated daily, except for total distance and number of geopoints tracked, which are being updated on the fly. Also, charts with yearly and monthly stats are being updated every hour.
## Fixed
- Tracked distance on year card on the Stats page will always be equal to the sum of distances on the monthly chart below it. #466
- Stats are now being calculated for trial users as well as active ones.
# [0.31.0] - 2025-09-04 # [0.31.0] - 2025-09-04
The Search release The Search release

File diff suppressed because one or more lines are too long

View file

@ -29,58 +29,6 @@ module ApplicationHelper
%w[info success warning error accent secondary primary] %w[info success warning error accent secondary primary]
end end
def countries_and_cities_stat_for_year(year, stats)
data = { countries: [], cities: [] }
stats.select { _1.year == year }.each do
data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
end
data[:cities].flatten!.uniq!
data[:countries].flatten!.uniq!
grouped_by_country = {}
stats.select { _1.year == year }.each do |stat|
stat.toponyms.flatten.each do |toponym|
country = toponym['country']
next unless country.present?
grouped_by_country[country] ||= []
next unless toponym['cities'].present?
toponym['cities'].each do |city_data|
city = city_data['city']
grouped_by_country[country] << city if city.present?
end
end
end
grouped_by_country.transform_values!(&:uniq)
{
countries_count: data[:countries].count,
cities_count: data[:cities].count,
grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
year: year,
modal_id: "countries_cities_modal_#{year}"
}
end
def countries_and_cities_stat_for_month(stat)
countries = stat.toponyms.count { _1['country'] }
cities = stat.toponyms.sum { _1['cities'].count }
"#{countries} countries, #{cities} cities"
end
def year_distance_stat(year, user)
# Distance is now stored in meters, convert to user's preferred unit for display
total_distance_meters = Stat.year_distance(year, user).sum { _1[1] }
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
end
def past?(year, month) def past?(year, month)
DateTime.new(year, month).past? DateTime.new(year, month).past?
end end

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
module StatsHelper
def year_distance_stat(year_data, user)
total_distance_meters = year_data.sum { _1[1] }
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
end
def countries_and_cities_stat_for_year(year, stats)
data = { countries: [], cities: [] }
stats.select { _1.year == year }.each do
data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
end
data[:cities].flatten!.uniq!
data[:countries].flatten!.uniq!
grouped_by_country = {}
stats.select { _1.year == year }.each do |stat|
stat.toponyms.flatten.each do |toponym|
country = toponym['country']
next if country.blank?
grouped_by_country[country] ||= []
next if toponym['cities'].blank?
toponym['cities'].each do |city_data|
city = city_data['city']
grouped_by_country[country] << city if city.present?
end
end
end
grouped_by_country.transform_values!(&:uniq)
{
countries_count: data[:countries].count,
cities_count: data[:cities].count,
grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
year: year,
modal_id: "countries_cities_modal_#{year}"
}
end
def countries_and_cities_stat_for_month(stat)
countries = stat.toponyms.count { _1['country'] }
cities = stat.toponyms.sum { _1['cities'].count }
"#{countries} countries, #{cities} cities"
end
end

View file

@ -4,7 +4,7 @@ class BulkStatsCalculatingJob < ApplicationJob
queue_as :stats queue_as :stats
def perform def perform
user_ids = User.active.pluck(:id) user_ids = User.active.pluck(:id) + User.trial.pluck(:id)
user_ids.each do |user_id| user_ids.each do |user_id|
Stats::BulkCalculator.new(user_id).call Stats::BulkCalculator.new(user_id).call

View file

@ -10,6 +10,24 @@ class Cache::PreheatingJob < ApplicationJob
user.years_tracked, user.years_tracked,
expires_in: 1.day expires_in: 1.day
) )
Rails.cache.write(
"dawarich/user_#{user.id}_points_geocoded_stats",
StatsQuery.new(user).send(:cached_points_geocoded_stats),
expires_in: 1.day
)
Rails.cache.write(
"dawarich/user_#{user.id}_countries_visited",
user.send(:countries_visited_uncached),
expires_in: 1.day
)
Rails.cache.write(
"dawarich/user_#{user.id}_cities_visited",
user.send(:cities_visited_uncached),
expires_in: 1.day
)
end end
end end
end end

View file

@ -6,8 +6,8 @@ class Users::MailerSendingJob < ApplicationJob
def perform(user_id, email_type, **options) def perform(user_id, email_type, **options)
user = User.find(user_id) user = User.find(user_id)
if trial_related_email?(email_type) && user.active? if should_skip_email?(user, email_type)
Rails.logger.info "Skipping #{email_type} email for user #{user_id} - user is already subscribed" Rails.logger.info "Skipping #{email_type} email for user #{user_id} - #{skip_reason(user, email_type)}"
return return
end end
@ -18,7 +18,33 @@ class Users::MailerSendingJob < ApplicationJob
private private
def trial_related_email?(email_type) def should_skip_email?(user, email_type)
%w[trial_expires_soon trial_expired].include?(email_type.to_s) case email_type.to_s
when 'trial_expires_soon', 'trial_expired'
user.active?
when 'post_trial_reminder_early', 'post_trial_reminder_late'
user.active? || !user.trial?
when 'subscription_expires_soon_early', 'subscription_expires_soon_late'
!user.active? || !user.active_until&.future?
when 'subscription_expired_early', 'subscription_expired_late'
user.active? || user.active_until&.future? || user.trial?
else
false
end
end
def skip_reason(user, email_type)
case email_type.to_s
when 'trial_expires_soon', 'trial_expired'
'user is already subscribed'
when 'post_trial_reminder_early', 'post_trial_reminder_late'
user.active? ? 'user is subscribed' : 'user is not in trial state'
when 'subscription_expires_soon_early', 'subscription_expires_soon_late'
'user is not active or subscription already expired'
when 'subscription_expired_early', 'subscription_expired_late'
'user is active, subscription not expired, or user is in trial'
else
'unknown reason'
end
end end
end end

View file

@ -24,4 +24,40 @@ class UsersMailer < ApplicationMailer
mail(to: @user.email, subject: '💔 Your Dawarich trial expired') mail(to: @user.email, subject: '💔 Your Dawarich trial expired')
end end
def post_trial_reminder_early
@user = params[:user]
mail(to: @user.email, subject: '🚀 Still interested in Dawarich? Subscribe now!')
end
def post_trial_reminder_late
@user = params[:user]
mail(to: @user.email, subject: '📍 Your location data is waiting - Subscribe to Dawarich')
end
def subscription_expires_soon_early
@user = params[:user]
mail(to: @user.email, subject: '⚠️ Your Dawarich subscription expires in 14 days')
end
def subscription_expires_soon_late
@user = params[:user]
mail(to: @user.email, subject: '🚨 Your Dawarich subscription expires in 2 days')
end
def subscription_expired_early
@user = params[:user]
mail(to: @user.email, subject: '💔 Your Dawarich subscription expired - Reactivate now')
end
def subscription_expired_late
@user = params[:user]
mail(to: @user.email, subject: '📍 Missing your location insights? Renew Dawarich subscription')
end
end end

View file

@ -18,6 +18,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
after_create :create_api_key after_create :create_api_key
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? } after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? } after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? }
after_commit :schedule_subscription_emails_on_activation, if: :should_schedule_subscription_emails?
before_save :sanitize_input before_save :sanitize_input
@ -35,15 +36,20 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
end end
def countries_visited def countries_visited
points Rails.cache.fetch("dawarich/user_#{id}_countries_visited", expires_in: 1.day) do
.where.not(country_name: [nil, '']) points
.distinct .without_raw_data
.pluck(:country_name) .where.not(country_name: [nil, ''])
.compact .distinct
.pluck(:country_name)
.compact
end
end end
def cities_visited def cities_visited
points.where.not(city: [nil, '']).distinct.pluck(:city).compact Rails.cache.fetch("dawarich/user_#{id}_cities_visited", expires_in: 1.day) do
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
end
end end
def total_distance def total_distance
@ -151,5 +157,63 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features') Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features')
Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon') Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon')
Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired') Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired')
schedule_post_trial_emails
end
def schedule_post_trial_emails
Users::MailerSendingJob.set(wait: 9.days).perform_later(id, 'post_trial_reminder_early')
Users::MailerSendingJob.set(wait: 14.days).perform_later(id, 'post_trial_reminder_late')
end
def schedule_subscription_expiry_emails
return unless active? && active_until&.future?
days_until_expiry = (active_until.to_date - Time.current.to_date).to_i
if days_until_expiry >= 14
Users::MailerSendingJob.set(wait: (days_until_expiry - 14).days).perform_later(id,
'subscription_expires_soon_early')
end
if days_until_expiry >= 2
Users::MailerSendingJob.set(wait: (days_until_expiry - 2).days).perform_later(id,
'subscription_expires_soon_late')
end
schedule_subscription_expired_emails
end
def schedule_subscription_expired_emails
return unless active? && active_until&.future?
days_until_expiry = (active_until.to_date - Time.current.to_date).to_i
Users::MailerSendingJob.set(wait: (days_until_expiry + 7).days).perform_later(id, 'subscription_expired_early')
Users::MailerSendingJob.set(wait: (days_until_expiry + 14).days).perform_later(id, 'subscription_expired_late')
end
def should_schedule_subscription_emails?
return false unless persisted?
# Schedule if status changed to active or active_until was updated for an active user
(saved_change_to_status? && status == 'active') ||
(saved_change_to_active_until? && active? && active_until&.future?)
end
def schedule_subscription_emails_on_activation
schedule_subscription_expiry_emails
end
def countries_visited_uncached
points
.without_raw_data
.where.not(country_name: [nil, ''])
.distinct
.pluck(:country_name)
.compact
end
def cities_visited_uncached
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
end end
end end

View file

@ -6,10 +6,25 @@ class StatsQuery
end end
def points_stats def points_stats
cached_stats = Rails.cache.fetch("dawarich/user_#{user.id}_points_geocoded_stats", expires_in: 1.day) do
cached_points_geocoded_stats
end
{
total: user.points_count,
geocoded: cached_stats[:geocoded],
without_data: cached_stats[:without_data]
}
end
private
attr_reader :user
def cached_points_geocoded_stats
sql = ActiveRecord::Base.sanitize_sql_array([ sql = ActiveRecord::Base.sanitize_sql_array([
<<~SQL.squish, <<~SQL.squish,
SELECT SELECT
COUNT(id) as total,
COUNT(reverse_geocoded_at) as geocoded, COUNT(reverse_geocoded_at) as geocoded,
COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data
FROM points FROM points
@ -21,13 +36,8 @@ class StatsQuery
result = Point.connection.select_one(sql) result = Point.connection.select_one(sql)
{ {
total: result['total'].to_i,
geocoded: result['geocoded'].to_i, geocoded: result['geocoded'].to_i,
without_data: result['without_data'].to_i without_data: result['without_data'].to_i
} }
end end
private
attr_reader :user
end end

View file

@ -7,6 +7,8 @@ class Cache::Clean
delete_control_flag delete_control_flag
delete_version_cache delete_version_cache
delete_years_tracked_cache delete_years_tracked_cache
delete_points_geocoded_stats_cache
delete_countries_cities_cache
Rails.logger.info('Cache cleaned') Rails.logger.info('Cache cleaned')
end end
@ -25,5 +27,18 @@ class Cache::Clean
Rails.cache.delete("dawarich/user_#{user.id}_years_tracked") Rails.cache.delete("dawarich/user_#{user.id}_years_tracked")
end end
end end
def delete_points_geocoded_stats_cache
User.find_each do |user|
Rails.cache.delete("dawarich/user_#{user.id}_points_geocoded_stats")
end
end
def delete_countries_cities_cache
User.find_each do |user|
Rails.cache.delete("dawarich/user_#{user.id}_countries_visited")
Rails.cache.delete("dawarich/user_#{user.id}_cities_visited")
end
end
end end
end end

View file

@ -1,7 +1,7 @@
<% content_for :title, 'Statistics' %> <% content_for :title, 'Statistics' %>
<div class="w-full my-5"> <div class="w-full my-5">
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200"> <div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200 relative">
<div class="stat text-center"> <div class="stat text-center">
<div class="stat-value text-primary"> <div class="stat-value text-primary">
<%= number_with_delimiter(current_user.total_distance.round) %> <%= current_user.safe_settings.distance_unit %> <%= number_with_delimiter(current_user.total_distance.round) %> <%= current_user.safe_settings.distance_unit %>
@ -21,6 +21,11 @@
<% end %> <% end %>
</div> </div>
<div class='text-xs text-gray-500 text-center mt-5'>
All stats data above except for total distance and number of geopoints tracked is being updated daily
</div>
<% if current_user.active? %> <% if current_user.active? %>
<%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %> <%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %>
<% end %> <% end %>
@ -40,9 +45,7 @@
</div> </div>
</h2> </h2>
<p> <p>
<% cache [current_user, 'year_distance_stat', year], skip_digest: true do %> <%= number_with_delimiter year_distance_stat(@year_distances[year], current_user).round %> <%= current_user.safe_settings.distance_unit %>
<%= number_with_delimiter year_distance_stat(year, current_user).round %> <%= current_user.safe_settings.distance_unit %>
<% end %>
</p> </p>
<% if DawarichSettings.reverse_geocoding_enabled? %> <% if DawarichSettings.reverse_geocoding_enabled? %>
<div class="card-actions justify-end"> <div class="card-actions justify-end">

View file

@ -3,10 +3,8 @@
Rails.application.config.after_initialize do Rails.application.config.after_initialize do
# Only run in server mode and ensure one-time execution with atomic write # Only run in server mode and ensure one-time execution with atomic write
if defined?(Rails::Server) && Rails.cache.write('cache_jobs_scheduled', true, unless_exist: true) if defined?(Rails::Server) && Rails.cache.write('cache_jobs_scheduled', true, unless_exist: true)
# Clear the cache
Cache::CleaningJob.perform_later Cache::CleaningJob.perform_later
# Preheat the cache
Cache::PreheatingJob.perform_later Cache::PreheatingJob.perform_later
end end
end end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class AddUserCountryCompositeIndexToPoints < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :points, %i[user_id country_name],
algorithm: :concurrently,
name: 'idx_points_user_country_name',
if_not_exists: true
end
end

3
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do ActiveRecord::Schema[8.0].define(version: 2025_09_05_120121) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
enable_extension "postgis" enable_extension "postgis"
@ -205,6 +205,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
t.index ["timestamp"], name: "index_points_on_timestamp" t.index ["timestamp"], name: "index_points_on_timestamp"
t.index ["track_id"], name: "index_points_on_track_id" t.index ["track_id"], name: "index_points_on_track_id"
t.index ["trigger"], name: "index_points_on_trigger" t.index ["trigger"], name: "index_points_on_trigger"
t.index ["user_id", "country_name"], name: "idx_points_user_country_name"
t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation" t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation"
t.index ["user_id"], name: "index_points_on_user_id" t.index ["user_id"], name: "index_points_on_user_id"
t.index ["visit_id"], name: "index_points_on_visit_id" t.index ["visit_id"], name: "index_points_on_visit_id"

View file

@ -4,28 +4,153 @@ require 'rails_helper'
RSpec.describe BulkStatsCalculatingJob, type: :job do RSpec.describe BulkStatsCalculatingJob, type: :job do
describe '#perform' do describe '#perform' do
let(:user1) { create(:user) }
let(:user2) { create(:user) }
let(:timestamp) { DateTime.new(2024, 1, 1).to_i } let(:timestamp) { DateTime.new(2024, 1, 1).to_i }
let!(:points1) do context 'with active users' do
(1..10).map do |i| let!(:active_user1) { create(:user, status: :active) }
create(:point, user_id: user1.id, timestamp: timestamp + i.minutes) let!(:active_user2) { create(:user, status: :active) }
let!(:points1) do
(1..10).map do |i|
create(:point, user_id: active_user1.id, timestamp: timestamp + i.minutes)
end
end
let!(:points2) do
(1..10).map do |i|
create(:point, user_id: active_user2.id, timestamp: timestamp + i.minutes)
end
end
before do
# Remove any leftover users from other tests, keeping only our test users
User.where.not(id: [active_user1.id, active_user2.id]).destroy_all
allow(Stats::BulkCalculator).to receive(:new).and_call_original
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
end
it 'processes all active users' do
BulkStatsCalculatingJob.perform_now
expect(Stats::BulkCalculator).to have_received(:new).with(active_user1.id)
expect(Stats::BulkCalculator).to have_received(:new).with(active_user2.id)
end
it 'calls Stats::BulkCalculator for each active user' do
calculator1 = instance_double(Stats::BulkCalculator)
calculator2 = instance_double(Stats::BulkCalculator)
allow(Stats::BulkCalculator).to receive(:new).with(active_user1.id).and_return(calculator1)
allow(Stats::BulkCalculator).to receive(:new).with(active_user2.id).and_return(calculator2)
allow(calculator1).to receive(:call)
allow(calculator2).to receive(:call)
BulkStatsCalculatingJob.perform_now
expect(calculator1).to have_received(:call)
expect(calculator2).to have_received(:call)
end end
end end
let!(:points2) do context 'with trial users' do
(1..10).map do |i| let!(:trial_user1) { create(:user, status: :trial) }
create(:point, user_id: user2.id, timestamp: timestamp + i.minutes) let!(:trial_user2) { create(:user, status: :trial) }
let!(:points1) do
(1..5).map do |i|
create(:point, user_id: trial_user1.id, timestamp: timestamp + i.minutes)
end
end
let!(:points2) do
(1..5).map do |i|
create(:point, user_id: trial_user2.id, timestamp: timestamp + i.minutes)
end
end
before do
# Remove any leftover users from other tests, keeping only our test users
User.where.not(id: [trial_user1.id, trial_user2.id]).destroy_all
allow(Stats::BulkCalculator).to receive(:new).and_call_original
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
end
it 'processes all trial users' do
BulkStatsCalculatingJob.perform_now
expect(Stats::BulkCalculator).to have_received(:new).with(trial_user1.id)
expect(Stats::BulkCalculator).to have_received(:new).with(trial_user2.id)
end
it 'calls Stats::BulkCalculator for each trial user' do
calculator1 = instance_double(Stats::BulkCalculator)
calculator2 = instance_double(Stats::BulkCalculator)
allow(Stats::BulkCalculator).to receive(:new).with(trial_user1.id).and_return(calculator1)
allow(Stats::BulkCalculator).to receive(:new).with(trial_user2.id).and_return(calculator2)
allow(calculator1).to receive(:call)
allow(calculator2).to receive(:call)
BulkStatsCalculatingJob.perform_now
expect(calculator1).to have_received(:call)
expect(calculator2).to have_received(:call)
end end
end end
it 'enqueues Stats::CalculatingJob for each user' do context 'with inactive users only' do
expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1) before do
expect(Stats::CalculatingJob).to receive(:perform_later).with(user2.id, 2024, 1) allow(User).to receive(:active).and_return(User.none)
allow(User).to receive(:trial).and_return(User.none)
allow(Stats::BulkCalculator).to receive(:new)
end
BulkStatsCalculatingJob.perform_now it 'does not process any users when no active or trial users exist' do
BulkStatsCalculatingJob.perform_now
expect(Stats::BulkCalculator).not_to have_received(:new)
end
it 'queries for active and trial users but finds none' do
BulkStatsCalculatingJob.perform_now
expect(User).to have_received(:active)
expect(User).to have_received(:trial)
end
end
context 'with mixed user types' do
let(:active_user) { create(:user, status: :active) }
let(:trial_user) { create(:user, status: :trial) }
let(:inactive_user) { create(:user, status: :inactive) }
before do
active_users_relation = double('ActiveRecord::Relation')
trial_users_relation = double('ActiveRecord::Relation')
allow(active_users_relation).to receive(:pluck).with(:id).and_return([active_user.id])
allow(trial_users_relation).to receive(:pluck).with(:id).and_return([trial_user.id])
allow(User).to receive(:active).and_return(active_users_relation)
allow(User).to receive(:trial).and_return(trial_users_relation)
allow(Stats::BulkCalculator).to receive(:new).and_call_original
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
end
it 'processes only active and trial users, skipping inactive users' do
BulkStatsCalculatingJob.perform_now
expect(Stats::BulkCalculator).to have_received(:new).with(active_user.id)
expect(Stats::BulkCalculator).to have_received(:new).with(trial_user.id)
expect(Stats::BulkCalculator).not_to have_received(:new).with(inactive_user.id)
end
it 'processes exactly 2 users (active and trial)' do
BulkStatsCalculatingJob.perform_now
expect(Stats::BulkCalculator).to have_received(:new).exactly(2).times
end
end end
end end
end end

69
spec/jobs/cache/preheating_job_spec.rb vendored Normal file
View file

@ -0,0 +1,69 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Cache::PreheatingJob do
before do
Rails.cache.clear
end
describe '#perform' do
let!(:user1) { create(:user) }
let!(:user2) { create(:user) }
let!(:import1) { create(:import, user: user1) }
let!(:import2) { create(:import, user: user2) }
before do
create_list(:point, 3, user: user1, import: import1, reverse_geocoded_at: Time.current)
create_list(:point, 2, user: user2, import: import2, reverse_geocoded_at: Time.current)
end
it 'preheats years_tracked cache for all users' do
expect(Rails.cache).to receive(:write).with(
"dawarich/user_#{user1.id}_years_tracked",
anything,
expires_in: 1.day
)
expect(Rails.cache).to receive(:write).with(
"dawarich/user_#{user2.id}_years_tracked",
anything,
expires_in: 1.day
)
described_class.new.perform
end
it 'preheats points_geocoded_stats cache for all users' do
expect(Rails.cache).to receive(:write).with(
"dawarich/user_#{user1.id}_points_geocoded_stats",
{ geocoded: 3, without_data: 0 },
expires_in: 1.day
)
expect(Rails.cache).to receive(:write).with(
"dawarich/user_#{user2.id}_points_geocoded_stats",
{ geocoded: 2, without_data: 0 },
expires_in: 1.day
)
described_class.new.perform
end
it 'actually writes to cache' do
described_class.new.perform
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be true
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be true
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be true
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be true
end
it 'handles users with no points gracefully' do
user_no_points = create(:user)
expect { described_class.new.perform }.not_to raise_error
cached_stats = Rails.cache.read("dawarich/user_#{user_no_points.id}_points_geocoded_stats")
expect(cached_stats).to eq({ geocoded: 0, without_data: 0 })
end
end
end

View file

@ -3,6 +3,9 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe StatsQuery do RSpec.describe StatsQuery do
before do
Rails.cache.clear
end
describe '#points_stats' do describe '#points_stats' do
subject(:points_stats) { described_class.new(user).points_stats } subject(:points_stats) { described_class.new(user).points_stats }
@ -126,5 +129,46 @@ RSpec.describe StatsQuery do
}) })
end end
end end
describe 'caching behavior' do
let!(:points) do
create_list(:point, 2,
user: user,
import: import,
reverse_geocoded_at: Time.current,
geodata: { 'address' => 'Test Address' })
end
it 'caches the geocoded stats' do
expect(Rails.cache).to receive(:fetch).with(
"dawarich/user_#{user.id}_points_geocoded_stats",
expires_in: 1.day
).and_call_original
points_stats
end
it 'returns cached results on subsequent calls' do
# First call - should hit database and cache
expect(Point.connection).to receive(:select_one).once.and_call_original
first_result = points_stats
# Second call - should use cache, not hit database
expect(Point.connection).not_to receive(:select_one)
second_result = points_stats
expect(first_result).to eq(second_result)
end
it 'uses counter cache for total count' do
# Ensure counter cache is set correctly
user.reload
expect(user.points_count).to eq(2)
# The total should come from counter cache, not from SQL
result = points_stats
expect(result[:total]).to eq(user.points_count)
end
end
end end
end end

88
spec/services/cache/clean_spec.rb vendored Normal file
View file

@ -0,0 +1,88 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Cache::Clean do
before do
Rails.cache.clear
end
describe '.call' do
let!(:user1) { create(:user) }
let!(:user2) { create(:user) }
before do
# Set up cache entries that should be cleaned
Rails.cache.write('cache_jobs_scheduled', true)
Rails.cache.write(CheckAppVersion::VERSION_CACHE_KEY, '1.0.0')
Rails.cache.write("dawarich/user_#{user1.id}_years_tracked", { 2023 => ['Jan', 'Feb'] })
Rails.cache.write("dawarich/user_#{user2.id}_years_tracked", { 2023 => ['Mar', 'Apr'] })
Rails.cache.write("dawarich/user_#{user1.id}_points_geocoded_stats", { geocoded: 5, without_data: 2 })
Rails.cache.write("dawarich/user_#{user2.id}_points_geocoded_stats", { geocoded: 3, without_data: 1 })
end
it 'deletes control flag cache' do
expect(Rails.cache.exist?('cache_jobs_scheduled')).to be true
described_class.call
expect(Rails.cache.exist?('cache_jobs_scheduled')).to be false
end
it 'deletes version cache' do
expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be true
described_class.call
expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be false
end
it 'deletes years tracked cache for all users' do
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be true
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be true
described_class.call
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be false
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be false
end
it 'deletes points geocoded stats cache for all users' do
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be true
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be true
described_class.call
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be false
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be false
end
it 'logs cache cleaning process' do
expect(Rails.logger).to receive(:info).with('Cleaning cache...')
expect(Rails.logger).to receive(:info).with('Cache cleaned')
described_class.call
end
it 'handles users being added during execution gracefully' do
# Create a user that will be found during the cleaning process
user3 = nil
allow(User).to receive(:find_each).and_yield(user1).and_yield(user2) do |&block|
# Create a new user while iterating - this should not cause errors
user3 = create(:user)
Rails.cache.write("dawarich/user_#{user3.id}_years_tracked", { 2023 => ['May'] })
Rails.cache.write("dawarich/user_#{user3.id}_points_geocoded_stats", { geocoded: 1, without_data: 0 })
# Continue with the original block
[user1, user2].each(&block)
end
expect { described_class.call }.not_to raise_error
# The new user's cache should still exist since it wasn't processed
expect(Rails.cache.exist?("dawarich/user_#{user3.id}_years_tracked")).to be true
expect(Rails.cache.exist?("dawarich/user_#{user3.id}_points_geocoded_stats")).to be true
end
end
end