Merge pull request #1734 from Freika/feature/follow-up-emails

Add follow up emails
This commit is contained in:
Evgenii Burmakin 2025-09-13 16:11:07 +02:00 committed by GitHub
commit 608fa41fa8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 830 additions and 187 deletions

View file

@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# [UNRELEASED] # [UNRELEASED]
## 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.
## Added ## Added
- A cron job to generate daily tracks for users with new points since their last track generation. Being run every 4 hours. - A cron job to generate daily tracks for users with new points since their last track generation. Being run every 4 hours.
@ -14,6 +19,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## Changed ## 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.
- Minor versions are now being built only for amd64 architecture to speed up the build process. - Minor versions are now being built only for amd64 architecture to speed up the build process.
- If user is not authorized to see a page, they will be redirected to the home page with appropriate message instead of seeing an error. - If user is not authorized to see a page, they will be redirected to the home page with appropriate message instead of seeing an error.

File diff suppressed because one or more lines are too long

View file

@ -21,58 +21,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 new_version_available? def new_version_available?
CheckAppVersion.new.call CheckAppVersion.new.call
end end

View file

@ -1,6 +1,58 @@
# frozen_string_literal: true # frozen_string_literal: true
module StatsHelper 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
def distance_traveled(user, stat) def distance_traveled(user, stat)
distance_unit = user.safe_settings.distance_unit distance_unit = user.safe_settings.distance_unit

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,12 @@ 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" ExceptionReporter.call(
'Users::MailerSendingJob',
"Skipping #{email_type} email for user ID #{user_id} - #{skip_reason(user, email_type)}"
)
return return
end end
@ -15,12 +19,33 @@ class Users::MailerSendingJob < ApplicationJob
UsersMailer.with(params).public_send(email_type).deliver_later UsersMailer.with(params).public_send(email_type).deliver_later
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
Rails.logger.warn "User with ID #{user_id} not found. Skipping #{email_type} email." ExceptionReporter.call(
'Users::MailerSendingJob',
"User with ID #{user_id} not found. Skipping #{email_type} email."
)
end end
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?
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'
else
'unknown reason'
end
end end
end end

View file

@ -2,26 +2,44 @@
class UsersMailer < ApplicationMailer class UsersMailer < ApplicationMailer
def welcome def welcome
# Sent after user signs up
@user = params[:user] @user = params[:user]
mail(to: @user.email, subject: 'Welcome to Dawarich!') mail(to: @user.email, subject: 'Welcome to Dawarich!')
end end
def explore_features def explore_features
# Sent 2 days after user signs up
@user = params[:user] @user = params[:user]
mail(to: @user.email, subject: 'Explore Dawarich features!') mail(to: @user.email, subject: 'Explore Dawarich features!')
end end
def trial_expires_soon def trial_expires_soon
# Sent 2 days before trial expires
@user = params[:user] @user = params[:user]
mail(to: @user.email, subject: '⚠️ Your Dawarich trial expires in 2 days') mail(to: @user.email, subject: '⚠️ Your Dawarich trial expires in 2 days')
end end
def trial_expired def trial_expired
# Sent when trial expires
@user = params[:user] @user = params[:user]
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
# Sent 2 days after trial expires
@user = params[:user]
mail(to: @user.email, subject: '🚀 Still interested in Dawarich? Subscribe now!')
end
def post_trial_reminder_late
# Sent 7 days after trial expires
@user = params[:user]
mail(to: @user.email, subject: '📍 Your location data is waiting - Subscribe to Dawarich')
end
end end

View file

@ -36,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
@ -156,5 +161,24 @@ 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 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,10 @@
<% 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 +44,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

@ -17,12 +17,17 @@
<h1>Explore Dawarich Features</h1> <h1>Explore Dawarich Features</h1>
</div> </div>
<div class="content"> <div class="content">
<p>Hi <%= @user.email %>,</p> <p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
<p>You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.</p> <p>You're now 2 days into your Dawarich trial! I hope you're enjoying tracking your location data.</p>
<p>Here are some powerful features you might want to explore:</p> <p>Here are some powerful features you might want to explore:</p>
<div class="feature">
<h3>✈️ Reliving your travels</h3>
<p>Revisit your past journeys with detailed maps and insights.</p>
</div>
<div class="feature"> <div class="feature">
<h3>📊 Statistics & Analytics</h3> <h3>📊 Statistics & Analytics</h3>
<p>View detailed insights about distances traveled and time spent in different locations.</p> <p>View detailed insights about distances traveled and time spent in different locations.</p>

View file

@ -1,11 +1,14 @@
Explore Dawarich Features Explore Dawarich Features
Hi <%= @user.email %>, Hi <%= @user.email %>, this is Evgenii from Dawarich.
You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data. You're now 2 days into your Dawarich trial! I hope you're enjoying tracking your location data.
Here are some powerful features you might want to explore: Here are some powerful features you might want to explore:
✈️ Reliving your travels
Revisit your past journeys with detailed maps and insights.
📊 Statistics & Analytics 📊 Statistics & Analytics
View detailed insights about distances traveled and time spent in different locations. View detailed insights about distances traveled and time spent in different locations.

View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #2563eb; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9fafb; }
.cta { background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.reminder { background: #dbeafe; border: 1px solid #2563eb; padding: 15px; border-radius: 6px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 Still Interested in Dawarich?</h1>
</div>
<div class="content">
<p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
<div class="reminder">
<p><strong>Your Dawarich trial ended 2 days ago.</strong></p>
</div>
<p>I noticed you haven't subscribed yet, but I don't want you to miss out on the amazing features Dawarich has to offer!</p>
<p>Your location data is still safely stored and waiting for you for 365 days. With a subscription, you can pick up exactly where you left off.</p>
<h3>🌟 What you're missing:</h3>
<ul>
<li>Real-time location tracking and analysis</li>
<li>Beautiful, interactive maps with your travel history</li>
<li>Detailed statistics and insights about your journeys</li>
<li>Data export capabilities for your peace of mind</li>
</ul>
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=post_trial_reminder_early&utm_content=subscribe_now" class="cta">Subscribe Now</a>
<p>Ready to unlock your location story? Subscribe today and continue your journey with Dawarich!</p>
<p>Questions? Just reply to this email I'm here to help.</p>
<p>Best regards,<br>
Evgenii from Dawarich</p>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,24 @@
🚀 Still Interested in Dawarich?
Hi <%= @user.email %>,
Your Dawarich trial ended 2 days ago.
I noticed you haven't subscribed yet, but I don't want you to miss out on the amazing features Dawarich has to offer!
Your location data is still safely stored and waiting for you for 365 days. With a subscription, you can pick up exactly where you left off.
🌟 What you're missing:
- Real-time location tracking and analysis
- Beautiful, interactive maps with your travel history
- Detailed statistics and insights about your journeys
- Data export capabilities for your peace of mind
Subscribe now: https://my.dawarich.app
Ready to unlock your location story? Subscribe today and continue your journey with Dawarich!
Questions? Just reply to this email I'm here to help.
Best regards,
Evgenii from Dawarich

View file

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #059669; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9fafb; }
.cta { background: #059669; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.waiting { background: #d1fae5; border: 1px solid #059669; padding: 15px; border-radius: 6px; margin: 20px 0; }
.special { background: #fef3c7; border: 1px solid #f59e0b; padding: 15px; border-radius: 6px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📍 Your Location Data is Waiting</h1>
</div>
<div class="content">
<p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
<div class="waiting">
<p><strong>It's been a week since your Dawarich trial ended.</strong></p>
</div>
<p>Your location data is still safely stored and patiently waiting for you to return. I understand that choosing the right tool for your location tracking needs is important, and I wanted to reach out one more time.</p>
<h3>🗺️ Here's what's waiting for you:</h3>
<ul>
<li>All your location data, preserved and ready</li>
<li>Reliving your travels through detailed maps and insights</li>
<li>Privacy-first approach your data stays yours</li>
<li>Beautiful visualizations of your travel patterns</li>
<li>Regular updates and new features</li>
</ul>
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=post_trial_reminder_late&utm_content=special_offer" class="cta">Return to Dawarich</a>
<p>This is my final reminder about your trial. If Dawarich isn't the right fit for you right now, I completely understand. Your data will remain secure for the next year, and you're always welcome back.</p>
<p>Thank you for giving Dawarich a try. I hope to see you again soon!</p>
<p>Safe travels,<br>
Evgenii from Dawarich</p>
<p><em>P.S. If you have any questions or need assistance, just hit reply I'm here to help!</em></p>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,26 @@
📍 Your Location Data is Waiting
Hi <%= @user.email %>, this is Evgenii from Dawarich.
It's been a week since your Dawarich trial ended.
Your location data is still safely stored and patiently waiting for you to return. I understand that choosing the right tool for your location tracking needs is important, and I wanted to reach out one more time.
🗺️ Here's what's waiting for you:
- All your location data, preserved and ready
- Reliving your travels through detailed maps and insights
- Privacy-first approach your data stays yours
- Beautiful visualizations of your travel patterns
- Integration with popular location apps and services
- Regular updates and new features
Return to Dawarich: https://my.dawarich.app
This is my final reminder about your trial. If Dawarich isn't the right fit for you right now, I completely understand. Your data will remain secure for the next year, and you're always welcome back.
Thank you for giving Dawarich a try. I hope to see you again soon!
Safe travels,
Evgenii from Dawarich
P.S. If you have any questions or need assistance, just hit reply I'm here to help!

View file

@ -17,13 +17,13 @@
<h1>🔒 Your Trial Has Expired</h1> <h1>🔒 Your Trial Has Expired</h1>
</div> </div>
<div class="content"> <div class="content">
<p>Hi <%= @user.email %>,</p> <p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
<div class="expired"> <div class="expired">
<p><strong>Your 7-day Dawarich trial has ended.</strong></p> <p><strong>Your 7-day Dawarich trial has ended.</strong></p>
</div> </div>
<p>Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.</p> <p>Thank you for trying Dawarich! I hope you enjoyed exploring your location data over the past week.</p>
<p>Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.</p> <p>Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.</p>
@ -40,7 +40,7 @@
<p>Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!</p> <p>Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!</p>
<p>We'd love to have you back as a subscriber.</p> <p>I'd love to have you back as a subscriber.</p>
<p>Best regards,<br> <p>Best regards,<br>
Evgenii from Dawarich</p> Evgenii from Dawarich</p>

View file

@ -1,10 +1,10 @@
🔒 Your Trial Has Expired 🔒 Your Trial Has Expired
Hi <%= @user.email %>, Hi <%= @user.email %>, this is Evgenii from Dawarich.
Your 7-day Dawarich trial has ended. Your 7-day Dawarich trial has ended.
Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week. Thank you for trying Dawarich! I hope you enjoyed exploring your location data over the past week.
Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich. Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.
@ -19,7 +19,7 @@ Subscribe to continue: https://my.dawarich.app
Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off! Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!
We'd love to have you back as a subscriber. I'd love to have you back as a subscriber.
Best regards, Best regards,
Evgenii from Dawarich Evgenii from Dawarich

View file

@ -17,13 +17,13 @@
<h1>⏰ Your Trial Expires Soon</h1> <h1>⏰ Your Trial Expires Soon</h1>
</div> </div>
<div class="content"> <div class="content">
<p>Hi <%= @user.email %>,</p> <p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
<div class="urgent"> <div class="urgent">
<p><strong>⚠️ Important:</strong> Your Dawarich trial expires in just <strong>2 days</strong>!</p> <p><strong>⚠️ Important:</strong> Your Dawarich trial expires in just <strong>2 days</strong>!</p>
</div> </div>
<p>We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.</p> <p>I hope you've enjoyed exploring your location data with Dawarich over the past 5 days.</p>
<p>To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.</p> <p>To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.</p>
@ -40,7 +40,7 @@
<p>Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!</p> <p>Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!</p>
<p>Questions? Drop us a message at hi@dawarich.app or just reply to this email.</p> <p>Questions? Drop me a message at hi@dawarich.app or just reply to this email.</p>
<p>Best regards,<br> <p>Best regards,<br>
Evgenii from Dawarich</p> Evgenii from Dawarich</p>

View file

@ -1,10 +1,10 @@
⏰ Your Trial Expires Soon ⏰ Your Trial Expires Soon
Hi <%= @user.email %>, Hi <%= @user.email %>, this is Evgenii from Dawarich.
⚠️ Important: Your Dawarich trial expires in just 2 days! ⚠️ Important: Your Dawarich trial expires in just 2 days!
We hope you've enjoyed exploring your location data with Dawarich over the past 5 days. I hope you've enjoyed exploring your location data with Dawarich over the past 5 days.
To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan. To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.
@ -19,7 +19,7 @@ Subscribe now: https://my.dawarich.app
Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich! Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!
Questions? Drop us a message at hi@dawarich.app Questions? Drop me a message at hi@dawarich.app or just reply to this email.
Best regards, Best regards,
Evgenii from Dawarich Evgenii from Dawarich

View file

@ -16,9 +16,9 @@
<h1>Welcome to Dawarich!</h1> <h1>Welcome to Dawarich!</h1>
</div> </div>
<div class="content"> <div class="content">
<p>Hi <%= @user.email %>,</p> <p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
<p>Welcome to Dawarich! We're excited to have you on board.</p> <p>Welcome to Dawarich! I'm excited to have you on board.</p>
<p>Your 7-day free trial has started. During this time, you can:</p> <p>Your 7-day free trial has started. During this time, you can:</p>
<ul> <ul>
@ -30,7 +30,7 @@
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=welcome&utm_content=start_exploring" class="cta">Start Exploring Dawarich</a> <a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=welcome&utm_content=start_exploring" class="cta">Start Exploring Dawarich</a>
<p>If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.</p> <p>If you have any questions, feel free to drop me a message at hi@dawarich.app or just reply to this email.</p>
<p>Happy tracking!<br> <p>Happy tracking!<br>
Evgenii from Dawarich</p> Evgenii from Dawarich</p>

View file

@ -1,8 +1,8 @@
Welcome to Dawarich! Welcome to Dawarich!
Hi <%= @user.email %>, Hi <%= @user.email %>, this is Evgenii from Dawarich.
Welcome to Dawarich! We're excited to have you on board. Welcome to Dawarich! I'm excited to have you on board.
Your 7-day free trial has started. During this time, you can: Your 7-day free trial has started. During this time, you can:
- Track your location data - Track your location data
@ -12,7 +12,7 @@ Your 7-day free trial has started. During this time, you can:
Start exploring Dawarich: https://my.dawarich.app Start exploring Dawarich: https://my.dawarich.app
If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email. If you have any questions, feel free to drop me a message at hi@dawarich.app or just reply to this email.
Happy tracking! Happy tracking!
Evgenii from Dawarich Evgenii from Dawarich

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

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

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

@ -0,0 +1,92 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Cache::PreheatingJob do
before { Rails.cache.clear }
describe '#perform' do
let!(:user1) { create(:user) }
let!(:user2) { create(:user) }
let!(:import1) { create(:import, user: user1) }
let!(:import2) { create(:import, user: user2) }
let(:user_1_years_tracked_key) { "dawarich/user_#{user1.id}_years_tracked" }
let(:user_2_years_tracked_key) { "dawarich/user_#{user2.id}_years_tracked" }
let(:user_1_points_geocoded_stats_key) { "dawarich/user_#{user1.id}_points_geocoded_stats" }
let(:user_2_points_geocoded_stats_key) { "dawarich/user_#{user2.id}_points_geocoded_stats" }
let(:user_1_countries_visited_key) { "dawarich/user_#{user1.id}_countries_visited" }
let(:user_2_countries_visited_key) { "dawarich/user_#{user2.id}_countries_visited" }
let(:user_1_cities_visited_key) { "dawarich/user_#{user1.id}_cities_visited" }
let(:user_2_cities_visited_key) { "dawarich/user_#{user2.id}_cities_visited" }
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
# Clear cache before test to ensure clean state
Rails.cache.clear
described_class.new.perform
# Verify that cache keys exist after job runs
expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true
expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true
# Verify the cached data is reasonable
user1_years = Rails.cache.read(user_1_years_tracked_key)
user2_years = Rails.cache.read(user_2_years_tracked_key)
expect(user1_years).to be_an(Array)
expect(user2_years).to be_an(Array)
end
it 'preheats points_geocoded_stats cache for all users' do
# Clear cache before test to ensure clean state
Rails.cache.clear
described_class.new.perform
# Verify that cache keys exist after job runs
expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true
expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true
# Verify the cached data has the expected structure
user1_stats = Rails.cache.read(user_1_points_geocoded_stats_key)
user2_stats = Rails.cache.read(user_2_points_geocoded_stats_key)
expect(user1_stats).to be_a(Hash)
expect(user1_stats).to have_key(:geocoded)
expect(user1_stats).to have_key(:without_data)
expect(user1_stats[:geocoded]).to eq(3)
expect(user2_stats).to be_a(Hash)
expect(user2_stats).to have_key(:geocoded)
expect(user2_stats).to have_key(:without_data)
expect(user2_stats[:geocoded]).to eq(2)
end
it 'actually writes to cache' do
described_class.new.perform
expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true
expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true
expect(Rails.cache.exist?(user_1_countries_visited_key)).to be true
expect(Rails.cache.exist?(user_1_cities_visited_key)).to be true
expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true
expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true
expect(Rails.cache.exist?(user_2_countries_visited_key)).to be true
expect(Rails.cache.exist?(user_2_cities_visited_key)).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

@ -117,28 +117,4 @@ RSpec.describe Users::MailerSendingJob, type: :job do
end end
end end
end end
describe '#trial_related_email?' do
subject { described_class.new }
it 'returns true for trial_expires_soon' do
expect(subject.send(:trial_related_email?, 'trial_expires_soon')).to be true
end
it 'returns true for trial_expired' do
expect(subject.send(:trial_related_email?, 'trial_expired')).to be true
end
it 'returns false for welcome' do
expect(subject.send(:trial_related_email?, 'welcome')).to be false
end
it 'returns false for explore_features' do
expect(subject.send(:trial_related_email?, 'explore_features')).to be false
end
it 'returns false for unknown email types' do
expect(subject.send(:trial_related_email?, 'unknown_email')).to be false
end
end
end end

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require "rails_helper" require 'rails_helper'
RSpec.describe UsersMailer, type: :mailer do RSpec.describe UsersMailer, type: :mailer do
let(:user) { create(:user, email: 'test@example.com') } let(:user) { create(:user, email: 'test@example.com') }
@ -9,43 +9,61 @@ RSpec.describe UsersMailer, type: :mailer do
stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app')) stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app'))
end end
describe "welcome" do describe 'welcome' do
let(:mail) { UsersMailer.with(user: user).welcome } let(:mail) { UsersMailer.with(user: user).welcome }
it "renders the headers" do it 'renders the headers' do
expect(mail.subject).to eq("Welcome to Dawarich!") expect(mail.subject).to eq('Welcome to Dawarich!')
expect(mail.to).to eq(["test@example.com"]) expect(mail.to).to eq(['test@example.com'])
end end
it "renders the body" do it 'renders the body' do
expect(mail.body.encoded).to match("test@example.com") expect(mail.body.encoded).to match('test@example.com')
end end
end end
describe "explore_features" do describe 'explore_features' do
let(:mail) { UsersMailer.with(user: user).explore_features } let(:mail) { UsersMailer.with(user: user).explore_features }
it "renders the headers" do it 'renders the headers' do
expect(mail.subject).to eq("Explore Dawarich features!") expect(mail.subject).to eq('Explore Dawarich features!')
expect(mail.to).to eq(["test@example.com"]) expect(mail.to).to eq(['test@example.com'])
end end
end end
describe "trial_expires_soon" do describe 'trial_expires_soon' do
let(:mail) { UsersMailer.with(user: user).trial_expires_soon } let(:mail) { UsersMailer.with(user: user).trial_expires_soon }
it "renders the headers" do it 'renders the headers' do
expect(mail.subject).to eq("⚠️ Your Dawarich trial expires in 2 days") expect(mail.subject).to eq('⚠️ Your Dawarich trial expires in 2 days')
expect(mail.to).to eq(["test@example.com"]) expect(mail.to).to eq(['test@example.com'])
end end
end end
describe "trial_expired" do describe 'trial_expired' do
let(:mail) { UsersMailer.with(user: user).trial_expired } let(:mail) { UsersMailer.with(user: user).trial_expired }
it "renders the headers" do it 'renders the headers' do
expect(mail.subject).to eq("💔 Your Dawarich trial expired") expect(mail.subject).to eq('💔 Your Dawarich trial expired')
expect(mail.to).to eq(["test@example.com"]) expect(mail.to).to eq(['test@example.com'])
end
end
describe 'post_trial_reminder_early' do
let(:mail) { UsersMailer.with(user: user).post_trial_reminder_early }
it 'renders the headers' do
expect(mail.subject).to eq('🚀 Still interested in Dawarich? Subscribe now!')
expect(mail.to).to eq(['test@example.com'])
end
end
describe 'post_trial_reminder_late' do
let(:mail) { UsersMailer.with(user: user).post_trial_reminder_late }
it 'renders the headers' do
expect(mail.subject).to eq('📍 Your location data is waiting - Subscribe to Dawarich')
expect(mail.to).to eq(['test@example.com'])
end end
end end
end end

View file

@ -3,6 +3,8 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe StatsQuery do RSpec.describe StatsQuery do
before { Rails.cache.clear }
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 }
@ -11,11 +13,13 @@ RSpec.describe StatsQuery do
context 'when user has no points' do context 'when user has no points' do
it 'returns zero counts for all statistics' do it 'returns zero counts for all statistics' do
expect(points_stats).to eq({ expect(points_stats).to eq(
total: 0, {
geocoded: 0, total: 0,
without_data: 0 geocoded: 0,
}) without_data: 0
}
)
end end
end end
@ -45,11 +49,13 @@ RSpec.describe StatsQuery do
end end
it 'returns correct counts for all statistics' do it 'returns correct counts for all statistics' do
expect(points_stats).to eq({ expect(points_stats).to eq(
total: 3, {
geocoded: 2, total: 3,
without_data: 1 geocoded: 2,
}) without_data: 1
}
)
end end
context 'when another user has points' do context 'when another user has points' do
@ -64,11 +70,13 @@ RSpec.describe StatsQuery do
end end
it 'only counts points for the specified user' do it 'only counts points for the specified user' do
expect(points_stats).to eq({ expect(points_stats).to eq(
total: 3, {
geocoded: 2, total: 3,
without_data: 1 geocoded: 2,
}) without_data: 1
}
)
end end
end end
end end
@ -83,11 +91,13 @@ RSpec.describe StatsQuery do
end end
it 'returns correct statistics' do it 'returns correct statistics' do
expect(points_stats).to eq({ expect(points_stats).to eq(
total: 5, {
geocoded: 5, total: 5,
without_data: 0 geocoded: 5,
}) without_data: 0
}
)
end end
end end
@ -101,11 +111,13 @@ RSpec.describe StatsQuery do
end end
it 'returns correct statistics' do it 'returns correct statistics' do
expect(points_stats).to eq({ expect(points_stats).to eq(
total: 3, {
geocoded: 3, total: 3,
without_data: 3 geocoded: 3,
}) without_data: 3
}
)
end end
end end
@ -119,12 +131,55 @@ RSpec.describe StatsQuery do
end end
it 'returns correct statistics' do it 'returns correct statistics' do
expect(points_stats).to eq({ expect(points_stats).to eq(
total: 4, {
geocoded: 0, total: 4,
without_data: 0 geocoded: 0,
}) without_data: 0
}
)
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
end end

View file

@ -96,7 +96,7 @@ RSpec.describe 'Api::V1::Subscriptions', type: :request do
JWT.encode({ user_id: 'invalid', status: nil }, jwt_secret, 'HS256') JWT.encode({ user_id: 'invalid', status: nil }, jwt_secret, 'HS256')
end end
it 'returns unprocessable_entity error with invalid data message' do it 'returns unprocessable_content error with invalid data message' do
allow(Subscription::DecodeJwtToken).to receive(:new).with(token) allow(Subscription::DecodeJwtToken).to receive(:new).with(token)
.and_raise(ArgumentError.new('Invalid token data')) .and_raise(ArgumentError.new('Invalid token data'))

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

@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Cache::Clean do
before { Rails.cache.clear }
describe '.call' do
let!(:user1) { create(:user) }
let!(:user2) { create(:user) }
let(:user_1_years_tracked_key) { "dawarich/user_#{user1.id}_years_tracked" }
let(:user_2_years_tracked_key) { "dawarich/user_#{user2.id}_years_tracked" }
let(:user_1_points_geocoded_stats_key) { "dawarich/user_#{user1.id}_points_geocoded_stats" }
let(:user_2_points_geocoded_stats_key) { "dawarich/user_#{user2.id}_points_geocoded_stats" }
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(user_1_years_tracked_key, { 2023 => %w[Jan Feb] })
Rails.cache.write(user_2_years_tracked_key, { 2023 => %w[Mar Apr] })
Rails.cache.write(user_1_points_geocoded_stats_key, { geocoded: 5, without_data: 2 })
Rails.cache.write(user_2_points_geocoded_stats_key, { 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?(user_1_years_tracked_key)).to be true
expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true
described_class.call
expect(Rails.cache.exist?(user_1_years_tracked_key)).to be false
expect(Rails.cache.exist?(user_2_years_tracked_key)).to be false
end
it 'deletes points geocoded stats cache for all users' do
expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true
expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true
described_class.call
expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be false
expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).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