From 5544bcd5ffcbfda42476fcadc37db6111cd60eed Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 2 Apr 2024 17:37:38 +0200 Subject: [PATCH 1/5] Move point creation to a background job, add months navigation to the sidebar --- .rubocop.yml | 3 ++ Gemfile | 7 ++-- Gemfile.lock | 11 ++++++ Procfile.dev | 2 +- app/controllers/api/v1/points_controller.rb | 10 ++---- app/controllers/points_controller.rb | 5 +-- app/helpers/application_helper.rb | 24 +++++++++++-- app/jobs/point_creating_job.rb | 9 +++++ app/jobs/stat_creating_job.rb | 6 ++-- app/models/stat.rb | 6 ++++ app/services/countries_and_cities.rb | 8 +++-- app/services/create_stats.rb | 34 ++++++++++--------- app/views/layouts/application.html.erb | 2 +- app/views/points/index.html.erb | 4 +-- app/views/shared/_right_sidebar.html.erb | 30 ++++++++++++++++ app/views/stats/index.html.erb | 10 ++++-- config/initializers/00_constants.rb | 2 ++ config/schedule.yml | 6 ++++ spec/jobs/import_job_spec.rb | 13 ++++++- spec/jobs/point_creating_job_spec.rb | 15 ++++++++ spec/requests/api/v1/points_spec.rb | 15 +++----- .../services/own_tracks/export_parser_spec.rb | 2 +- 22 files changed, 168 insertions(+), 56 deletions(-) create mode 100644 app/jobs/point_creating_job.rb create mode 100644 config/schedule.yml create mode 100644 spec/jobs/point_creating_job_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index ea725c1b..aaa6befd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1 +1,4 @@ require: rubocop-rails + +Style/Documentation: + Enabled: false diff --git a/Gemfile b/Gemfile index 92b6a822..cb1e7c09 100644 --- a/Gemfile +++ b/Gemfile @@ -15,10 +15,11 @@ gem 'stimulus-rails' gem 'tailwindcss-rails' gem 'turbo-rails' gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] -gem "importmap-rails" -gem "chartkick" +gem 'importmap-rails' +gem 'chartkick' gem 'geocoder' gem 'sidekiq' +gem 'sidekiq-cron' group :development, :test do @@ -42,4 +43,4 @@ group :development do end # Use Redis for Action Cable -gem "redis" +gem 'redis' diff --git a/Gemfile.lock b/Gemfile.lock index 3a497dd8..a1d19b80 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,6 +106,8 @@ GEM railties (>= 6.1) drb (2.2.1) erubi (1.12.0) + et-orbi (1.2.11) + tzinfo factory_bot (6.4.6) activesupport (>= 5.0.0) factory_bot_rails (6.4.3) @@ -113,6 +115,9 @@ GEM railties (>= 5.0.0) ffaker (2.23.0) foreman (0.87.2) + fugit (1.10.1) + et-orbi (~> 1, >= 1.2.7) + raabro (~> 1.4) geocoder (1.8.2) globalid (1.2.1) activesupport (>= 6.1) @@ -178,6 +183,7 @@ GEM nio4r (~> 2.0) pundit (2.3.1) activesupport (>= 3.0.0) + raabro (1.4.0) racc (1.7.3) rack (3.0.10) rack-session (2.0.0) @@ -274,6 +280,10 @@ GEM connection_pool (>= 2.3.0) rack (>= 2.2.4) redis-client (>= 0.19.0) + sidekiq-cron (1.12.0) + fugit (~> 1.8) + globalid (>= 1.0.1) + sidekiq (>= 6) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -343,6 +353,7 @@ DEPENDENCIES rubocop-rails shoulda-matchers sidekiq + sidekiq-cron simplecov sprockets-rails stimulus-rails diff --git a/Procfile.dev b/Procfile.dev index adb58e23..67106a1e 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ web: bin/rails server -p 3000 -b 0.0.0.0 css: bin/rails tailwindcss:watch -worker: bundle exec sidekiq +worker: bundle exec sidekiq -C config/sidekiq.yml diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index aa1e280b..98aeea70 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -2,15 +2,9 @@ class Api::V1::PointsController < ApplicationController skip_forgery_protection def create - parsed_params = OwnTracks::Params.new(point_params).call + PointCreatingJob.perform_later(point_params) - @point = Point.create(parsed_params) - - if @point.valid? - render json: @point, status: :ok - else - render json: @point.errors, status: :unprocessable_entity - end + render json: {}, status: :ok end def destroy diff --git a/app/controllers/points_controller.rb b/app/controllers/points_controller.rb index 9c57fac5..ba1c3fc8 100644 --- a/app/controllers/points_controller.rb +++ b/app/controllers/points_controller.rb @@ -11,6 +11,7 @@ class PointsController < ApplicationController @distance = distance @start_at = Time.zone.at(start_at) @end_at = Time.zone.at(end_at) + @years = (@start_at.year..@end_at.year).to_a end private @@ -18,13 +19,13 @@ class PointsController < ApplicationController def start_at return 1.month.ago.beginning_of_day.to_i if params[:start_at].nil? - params[:start_at].to_datetime.to_i + Time.parse(params[:start_at]).to_i end def end_at return Time.zone.today.end_of_day.to_i if params[:end_at].nil? - params[:end_at].to_datetime.to_i + Time.parse(params[:end_at]).to_i end def distance diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f7a13f81..f871dd6a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -17,10 +17,18 @@ module ApplicationHelper end def year_timespan(year) - start_at = DateTime.new(year).beginning_of_year.to_time.strftime('%Y-%m-%dT%H:%M') - end_at = DateTime.new(year).end_of_year.to_time.strftime('%Y-%m-%dT%H:%M') + start_at = Time.utc(year).in_time_zone('Europe/Berlin').beginning_of_year.strftime('%Y-%m-%dT%H:%M') + end_at = Time.utc(year).in_time_zone('Europe/Berlin').end_of_year.strftime('%Y-%m-%dT%H:%M') - { start_at: start_at, end_at: end_at } + { start_at:, end_at: } + end + + def timespan(month, year) + month = DateTime.new(year, month).in_time_zone(Time.zone) + start_at = month.beginning_of_month.to_time.strftime('%Y-%m-%dT%H:%M') + end_at = month.end_of_month.to_time.strftime('%Y-%m-%dT%H:%M') + + { start_at:, end_at: } end def header_colors @@ -38,4 +46,14 @@ module ApplicationHelper def year_distance_stat_in_km(year) Stat.year_distance(year).sum { _1[1] } end + + def is_past?(year, month) + DateTime.new(year, month).past? + end + + def points_exist?(year, month) + Point.where( + timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month + ).exists? + end end diff --git a/app/jobs/point_creating_job.rb b/app/jobs/point_creating_job.rb new file mode 100644 index 00000000..c7899d04 --- /dev/null +++ b/app/jobs/point_creating_job.rb @@ -0,0 +1,9 @@ +class PointCreatingJob < ApplicationJob + queue_as :default + + def perform(point_params) + parsed_params = OwnTracks::Params.new(point_params).call + + point = Point.create(parsed_params) + end +end diff --git a/app/jobs/stat_creating_job.rb b/app/jobs/stat_creating_job.rb index 6ca0f9fe..610ad38c 100644 --- a/app/jobs/stat_creating_job.rb +++ b/app/jobs/stat_creating_job.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + class StatCreatingJob < ApplicationJob queue_as :default - def perform(user_id) - CreateStats.new(user_id).call + def perform(user_ids = nil) + CreateStats.new(user_ids).call end end diff --git a/app/models/stat.rb b/app/models/stat.rb index 45ec29dd..5250255a 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -51,4 +51,10 @@ class Stat < ApplicationRecord { countries: data.count, cities: data.sum { |country| country[:cities].count } } end + + def self.years + starting_year = pluck(:year).uniq.min || Time.current.year + + (starting_year..Time.current.year).to_a.reverse + end end diff --git a/app/services/countries_and_cities.rb b/app/services/countries_and_cities.rb index fa952448..873a3edd 100644 --- a/app/services/countries_and_cities.rb +++ b/app/services/countries_and_cities.rb @@ -37,8 +37,10 @@ class CountriesAndCities end end - def filter_cities(mapped_with_cities) + # In future, we would want to remove cities where user spent less than + # 1 hour per day + # Remove cities with less than MINIMUM_POINTS_IN_CITY mapped_with_cities.transform_values do |cities| cities.reject { |_, data| data[:points] < MINIMUM_POINTS_IN_CITY } @@ -48,8 +50,8 @@ class CountriesAndCities def normalize_result(hash) hash.map do |country, cities| { - country: country, - cities: cities.map { |city, data| { city: city, points: data[:points], timestamp: data[:timestamp] } } + country:, + cities: cities.map { |city, data| { city:, points: data[:points], timestamp: data[:timestamp] } } } end end diff --git a/app/services/create_stats.rb b/app/services/create_stats.rb index 04125574..b99a4fa3 100644 --- a/app/services/create_stats.rb +++ b/app/services/create_stats.rb @@ -1,32 +1,34 @@ # frozen_string_literal: true class CreateStats - attr_reader :years, :months, :user + attr_reader :years, :months, :users - def initialize(user_id) - @user = User.find(user_id) + def initialize(user_ids) + @users = User.where(id: user_ids) @years = (1970..Time.current.year).to_a @months = (1..12).to_a end def call - years.flat_map do |year| - months.map do |month| - beginning_of_month_timestamp = DateTime.new(year, month).beginning_of_month.to_i - end_of_month_timestamp = DateTime.new(year, month).end_of_month.to_i + users.each do |user| + years.each do |year| + months.each do |month| + beginning_of_month_timestamp = DateTime.new(year, month).beginning_of_month.to_i + end_of_month_timestamp = DateTime.new(year, month).end_of_month.to_i - points = points(beginning_of_month_timestamp, end_of_month_timestamp) - next if points.empty? + points = points(beginning_of_month_timestamp, end_of_month_timestamp) + next if points.empty? - stat = Stat.find_or_initialize_by(year: year, month: month, user: user) - stat.distance = distance(points) - stat.toponyms = toponyms(points) - stat.daily_distance = stat.distance_by_day - stat.save + stat = Stat.find_or_initialize_by(year: year, month: month, user: user) + stat.distance = distance(points) + stat.toponyms = toponyms(points) + stat.daily_distance = stat.distance_by_day + stat.save - stat + stat + end end - end.compact + end end private diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index d5b6db57..dbd7a696 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -6,7 +6,7 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> - + <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> diff --git a/app/views/points/index.html.erb b/app/views/points/index.html.erb index 544c5f0a..62637846 100644 --- a/app/views/points/index.html.erb +++ b/app/views/points/index.html.erb @@ -1,4 +1,4 @@ -
+
<%= form_with url: points_path, method: :get do |f| %>
@@ -32,7 +32,7 @@
-
+
<%= render 'shared/right_sidebar' %>
diff --git a/app/views/shared/_right_sidebar.html.erb b/app/views/shared/_right_sidebar.html.erb index 222d7dbf..4c9755ea 100644 --- a/app/views/shared/_right_sidebar.html.erb +++ b/app/views/shared/_right_sidebar.html.erb @@ -1,6 +1,36 @@ <%= "#{@distance} km" if @distance %> +
+ + + <% @years.each do |year| %> +

+ <%= year %> +

+ +
+ <% (1..12).to_a.each_slice(3) do |months| %> + <% months.each do |month_number| %> + <% if is_past?(year, month_number) && points_exist?(year, month_number) %> + <%= link_to Date::ABBR_MONTHNAMES[month_number], points_url(timespan(month_number, year)), class: 'btn btn-default' %> + <% else %> +
<%= Date::ABBR_MONTHNAMES[month_number] %>
+ <% end %> + <% end %> + <% end %> +
+ <% end %> +
+ <% if REVERSE_GEOCODING_ENABLED && @countries_and_cities&.any? %> +
<% @countries_and_cities.each do |country| %>

<%= country[:country] %> (<%= country[:cities].count %> cities) diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index 0c6a4a70..a03d12d5 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -48,10 +48,16 @@ <%= link_to year, "/stats/#{year}", class: 'underline hover:no-underline' %> <%= link_to '[Map]', points_url(year_timespan(year)), class: 'underline hover:no-underline' %>

-

<%= number_with_delimiter year_distance_stat_in_km(year) %>km

+

+ <% cache [current_user, 'year_distance_stat_in_km', year], skip_digest: true do %> + <%= number_with_delimiter year_distance_stat_in_km(year) %>km + <% end %> +

<% if REVERSE_GEOCODING_ENABLED %>
- <%= countries_and_cities_stat(year) %> + <% cache [current_user, 'countries_and_cities_stat', year], skip_digest: true do %> + <%= countries_and_cities_stat(year) %> + <% end %>
<% end %> <%= column_chart( diff --git a/config/initializers/00_constants.rb b/config/initializers/00_constants.rb index 7c2d3dc6..593675c1 100644 --- a/config/initializers/00_constants.rb +++ b/config/initializers/00_constants.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + MINIMUM_POINTS_IN_CITY = ENV.fetch('MINIMUM_POINTS_IN_CITY', 5).to_i MAP_CENTER = ENV.fetch('MAP_CENTER', '[55.7522, 37.6156]') REVERSE_GEOCODING_ENABLED = ENV.fetch('REVERSE_GEOCODING_ENABLED', 'true') == 'true' diff --git a/config/schedule.yml b/config/schedule.yml new file mode 100644 index 00000000..ecd07f21 --- /dev/null +++ b/config/schedule.yml @@ -0,0 +1,6 @@ +# config/schedule.yml + +stat_creating_job: + cron: "0 */6 * * *" + class: "StatCreatingJob" + queue: default diff --git a/spec/jobs/import_job_spec.rb b/spec/jobs/import_job_spec.rb index ad362007..97acb372 100644 --- a/spec/jobs/import_job_spec.rb +++ b/spec/jobs/import_job_spec.rb @@ -1,5 +1,16 @@ require 'rails_helper' RSpec.describe ImportJob, type: :job do - pending "add some examples to (or delete) #{__FILE__}" + describe '#perform' do + subject(:perform) { described_class.new.perform(user.id, import.id) } + + let(:file_path) { 'spec/fixtures/owntracks_export.json' } + let(:file) { fixture_file_upload(file_path) } + let(:user) { create(:user) } + let(:import) { create(:import, user: user, file: file, name: File.basename(file.path)) } + + it 'creates points' do + expect { perform }.to change { Point.count }.by(8) + end + end end diff --git a/spec/jobs/point_creating_job_spec.rb b/spec/jobs/point_creating_job_spec.rb new file mode 100644 index 00000000..c5de9997 --- /dev/null +++ b/spec/jobs/point_creating_job_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe PointCreatingJob, type: :job do + describe '#perform' do + subject(:perform) { described_class.new.perform(point_params) } + + let(:point_params) do + { lat: 1.0, lon: 1.0, tid: 'test', tst: Time.now.to_i, topic: 'iPhone 12 pro' } + end + + it 'creates a point' do + expect { perform }.to change { Point.count }.by(1) + end + end +end diff --git a/spec/requests/api/v1/points_spec.rb b/spec/requests/api/v1/points_spec.rb index 9782ad70..f7b817d4 100644 --- a/spec/requests/api/v1/points_spec.rb +++ b/spec/requests/api/v1/points_spec.rb @@ -12,18 +12,11 @@ RSpec.describe "Api::V1::Points", type: :request do expect(response).to have_http_status(:success) end - end - context 'with invalid params' do - let(:params) do - { lat: 1.0, lon: 1.0, tid: 'test', tst: Time.now.to_i } - end - - it "returns http unprocessable_entity" do - post api_v1_points_path, params: params - - expect(response).to have_http_status(:unprocessable_entity) - expect(response.body).to eq("{\"topic\":[\"can't be blank\"]}") + it 'enqueues a job' do + expect { + post api_v1_points_path, params: params + }.to have_enqueued_job(PointCreatingJob) end end end diff --git a/spec/services/own_tracks/export_parser_spec.rb b/spec/services/own_tracks/export_parser_spec.rb index 0908f753..d261c762 100644 --- a/spec/services/own_tracks/export_parser_spec.rb +++ b/spec/services/own_tracks/export_parser_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe OwnTracks::ExportParser do describe '#call' do - subject(:parser) { described_class.new(import.id).call } + subject(:parser) { described_class.new(import).call } let(:file_path) { 'spec/fixtures/owntracks_export.json' } let(:file) { fixture_file_upload(file_path) } From 48962e87e8cee677ebb7a06e0227d8fe8656b1ab Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 2 Apr 2024 23:20:25 +0200 Subject: [PATCH 2/5] Add some unit tests --- app/models/stat.rb | 18 ++-- spec/factories/imports.rb | 3 +- spec/factories/points.rb | 4 +- spec/jobs/reverse_geocoding_job_spec.rb | 61 +++++++++++ spec/jobs/stat_creating_job_spec.rb | 17 ++- spec/models/stat_spec.rb | 133 +++++++++++++++++++++++- spec/models/user_spec.rb | 67 +++++++++++- 7 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 spec/jobs/reverse_geocoding_job_spec.rb diff --git a/app/models/stat.rb b/app/models/stat.rb index 5250255a..48d8e295 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -5,20 +5,16 @@ class Stat < ApplicationRecord belongs_to :user - def timespan - DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month - end - def distance_by_day timespan.to_a.map.with_index(1) do |day, index| beginning_of_day = day.beginning_of_day.to_i end_of_day = day.end_of_day.to_i - data = { day: index, distance: 0 } - # We have to filter by user as well points = Point.where(timestamp: beginning_of_day..end_of_day) + data = { day: index, distance: 0 } + points.each_cons(2) do |point1, point2| distance = Geocoder::Calculations.distance_between( [point1.latitude, point1.longitude], [point2.latitude, point2.longitude] @@ -49,12 +45,18 @@ class Stat < ApplicationRecord data = CountriesAndCities.new(points).call - { countries: data.count, cities: data.sum { |country| country[:cities].count } } + { countries: data.map { _1[:country] }.uniq.count, cities: data.sum { |country| country[:cities].count } } end def self.years - starting_year = pluck(:year).uniq.min || Time.current.year + starting_year = select(:year).min&.year || Time.current.year (starting_year..Time.current.year).to_a.reverse end + + private + + def timespan + DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month + end end diff --git a/spec/factories/imports.rb b/spec/factories/imports.rb index 817d2913..65b0fccf 100644 --- a/spec/factories/imports.rb +++ b/spec/factories/imports.rb @@ -1,6 +1,7 @@ FactoryBot.define do factory :import do - user_id { "" } + user + name { 'APRIL_2013.json' } source { 1 } end end diff --git a/spec/factories/points.rb b/spec/factories/points.rb index 4faefff9..ab04f8cd 100644 --- a/spec/factories/points.rb +++ b/spec/factories/points.rb @@ -21,7 +21,7 @@ FactoryBot.define do raw_data { "" } tracker_id { "MyString" } import_id { "" } - city { "MyString" } - country { "MyString" } + city { nil } + country { nil } end end diff --git a/spec/jobs/reverse_geocoding_job_spec.rb b/spec/jobs/reverse_geocoding_job_spec.rb new file mode 100644 index 00000000..b180f27b --- /dev/null +++ b/spec/jobs/reverse_geocoding_job_spec.rb @@ -0,0 +1,61 @@ +require 'rails_helper' + +RSpec.describe ReverseGeocodingJob, type: :job do + describe '#perform' do + subject(:perform) { described_class.new.perform(point.id) } + + let(:point) { create(:point) } + + before do + allow(Geocoder).to receive(:search).and_return([double(city: 'City', country: 'Country')]) + end + + context 'when REVERSE_GEOCODING_ENABLED is false' do + before { stub_const('REVERSE_GEOCODING_ENABLED', false) } + + it 'does not update point' do + expect { perform }.not_to change { point.reload.city } + end + + it 'does not call Geocoder' do + perform + + expect(Geocoder).not_to have_received(:search) + end + end + + context 'when REVERSE_GEOCODING_ENABLED is true' do + before { stub_const('REVERSE_GEOCODING_ENABLED', true) } + + it 'updates point with city and country' do + expect { perform }.to change { point.reload.city }.from(nil) + end + + it 'calls Geocoder' do + perform + + expect(Geocoder).to have_received(:search).with([point.latitude, point.longitude]) + end + + context 'when point has city and country' do + let(:point) { create(:point, city: 'City', country: 'Country') } + + before do + allow(Geocoder).to receive(:search).and_return( + [double(city: 'Another city', country: 'Some country')] + ) + end + + it 'does not update point' do + expect { perform }.not_to change { point.reload.city } + end + + it 'does not call Geocoder' do + perform + + expect(Geocoder).not_to have_received(:search) + end + end + end + end +end diff --git a/spec/jobs/stat_creating_job_spec.rb b/spec/jobs/stat_creating_job_spec.rb index 11c284fd..ff657893 100644 --- a/spec/jobs/stat_creating_job_spec.rb +++ b/spec/jobs/stat_creating_job_spec.rb @@ -1,5 +1,20 @@ require 'rails_helper' RSpec.describe StatCreatingJob, type: :job do - pending "add some examples to (or delete) #{__FILE__}" + describe '#perform' do + let(:user) { create(:user) } + + subject { described_class.perform_now([user.id]) } + + before do + allow(CreateStats).to receive(:new).and_call_original + allow_any_instance_of(CreateStats).to receive(:call) + end + + it 'creates a stat' do + subject + + expect(CreateStats).to have_received(:new).with([user.id]) + end + end end diff --git a/spec/models/stat_spec.rb b/spec/models/stat_spec.rb index 6280a126..a8bc6b7d 100644 --- a/spec/models/stat_spec.rb +++ b/spec/models/stat_spec.rb @@ -1,6 +1,135 @@ require 'rails_helper' RSpec.describe Stat, type: :model do - it { is_expected.to validate_presence_of(:year) } - it { is_expected.to validate_presence_of(:month) } + describe 'associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to validate_presence_of(:year) } + it { is_expected.to validate_presence_of(:month) } + end + + describe 'methods' do + let(:year) { 2021 } + + describe '.year_cities_and_countries' do + subject { described_class.year_cities_and_countries(year) } + + before do + stub_const('MINIMUM_POINTS_IN_CITY', 1) + end + + context 'when there are points' do + let!(:points) do + create_list(:point, 3, city: 'City', country: 'Country', timestamp: DateTime.new(year, 1)) + create_list(:point, 2, city: 'Some City', country: 'Another country', timestamp: DateTime.new(year, 2)) + end + + + it 'returns countries and cities' do + expect(subject).to eq(countries: 2, cities: 2) + end + end + + context 'when there are no points' do + it 'returns countries and cities' do + expect(subject).to eq(countries: 0, cities: 0) + end + end + end + + describe '.years' do + subject { described_class.years } + + context 'when there are no stats' do + it 'returns years' do + expect(subject).to eq([Time.current.year]) + end + end + + context 'when there are stats' do + let(:user) { create(:user) } + let(:expected_years) { (year..Time.current.year).to_a.reverse } + + before do + create(:stat, year: 2021, user: user) + create(:stat, year: 2020, user: user) + end + + it 'returns years' do + expect(subject).to eq(expected_years) + end + end + end + + describe '#distance_by_day' do + subject { stat.distance_by_day } + + let(:user) { create(:user) } + let(:stat) { create(:stat, year: year, month: 1, user: user) } + let(:expected_distance) do + # 31 day of January + (1..31).map { |day| [day, 0] } + end + + context 'when there are points' do + let!(:points) do + create(:point, latitude: 1, longitude: 1, timestamp: DateTime.new(year, 1, 1, 1)) + create(:point, latitude: 2, longitude: 2, timestamp: DateTime.new(year, 1, 1, 2)) + end + + before { expected_distance[0][1] = 157.23 } + + it 'returns distance by day' do + expect(subject).to eq(expected_distance) + end + end + + context 'when there are no points' do + it 'returns distance by day' do + expect(subject).to eq(expected_distance) + end + end + end + + describe '#timespan' do + subject { stat.send(:timespan) } + + let(:stat) { build(:stat, year: year, month: 1) } + let(:expected_timespan) { DateTime.new(year, 1).beginning_of_month..DateTime.new(year, 1).end_of_month } + + it 'returns timespan' do + expect(subject).to eq(expected_timespan) + end + end + + describe '#self.year_distance' do + subject { described_class.year_distance(year) } + + let(:user) { create(:user) } + let(:expected_distance) do + (1..12).map { |month| [Date::MONTHNAMES[month], 0] } + end + + context 'when there are stats' do + let!(:stats) do + create(:stat, year: year, month: 1, distance: 100, user: user) + create(:stat, year: year, month: 2, distance: 200, user: user) + end + + before do + expected_distance[0][1] = 100 + expected_distance[1][1] = 200 + end + + it 'returns year distance' do + expect(subject).to eq(expected_distance) + end + end + + context 'when there are no stats' do + it 'returns year distance' do + expect(subject).to eq(expected_distance) + end + end + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index b12bcf29..9809ac0a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,7 +1,68 @@ require 'rails_helper' RSpec.describe User, type: :model do - it { is_expected.to have_many(:imports).dependent(:destroy) } - it { is_expected.to have_many(:points).through(:imports) } - it { is_expected.to have_many(:stats) } + describe 'associations' do + it { is_expected.to have_many(:imports).dependent(:destroy) } + it { is_expected.to have_many(:points).through(:imports) } + it { is_expected.to have_many(:stats) } + end + + describe 'methods' do + let(:user) { create(:user) } + + xdescribe '#export_data' do + subject { user.export_data } + + let(:import) { create(:import, user: user) } + let(:point) { create(:point, import: import) } + + it 'returns json' do + expect(subject).to include(user.email) + expect(subject).to include('dawarich-export') + expect(subject).to include(point.attributes.except('raw_data', 'id', 'created_at', 'updated_at', 'country', 'city', 'import_id').to_json) + end + end + + describe '#total_km' do + subject { user.total_km } + + let!(:stat_1) { create(:stat, user: user, distance: 10) } + let!(:stat_2) { create(:stat, user: user, distance: 20) } + + it 'returns sum of distances' do + expect(subject).to eq(30) + end + end + + describe '#total_countries' do + subject { user.total_countries } + + let!(:stat) { create(:stat, user: user, toponyms: [{ 'country' => 'Country' }]) } + + it 'returns number of countries' do + expect(subject).to eq(1) + end + end + + describe '#total_cities' do + subject { user.total_cities } + + let!(:stat) { create(:stat, user: user, toponyms: [{ 'city' => 'City' }]) } + + it 'returns number of cities' do + expect(subject).to eq(1) + end + end + + describe '#total_reverse_geocoded' do + subject { user.total_reverse_geocoded } + + let(:import) { create(:import, user: user) } + let!(:point) { create(:point, country: 'Country', city: 'City', import: import) } + + it 'returns number of reverse geocoded points' do + expect(subject).to eq(1) + end + end + end end From 111667ce5a84009111a11fa7b5ed36aff1268ac2 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 2 Apr 2024 23:26:26 +0200 Subject: [PATCH 3/5] Add a spec for CreateStats service --- spec/jobs/import_job_spec.rb | 6 ++++++ spec/services/create_stats_spec.rb | 34 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 spec/services/create_stats_spec.rb diff --git a/spec/jobs/import_job_spec.rb b/spec/jobs/import_job_spec.rb index 97acb372..926400c8 100644 --- a/spec/jobs/import_job_spec.rb +++ b/spec/jobs/import_job_spec.rb @@ -12,5 +12,11 @@ RSpec.describe ImportJob, type: :job do it 'creates points' do expect { perform }.to change { Point.count }.by(8) end + + it 'calls StatCreatingJob' do + expect(StatCreatingJob).to receive(:perform_later).with(user.id) + + perform + end end end diff --git a/spec/services/create_stats_spec.rb b/spec/services/create_stats_spec.rb new file mode 100644 index 00000000..271116b5 --- /dev/null +++ b/spec/services/create_stats_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe CreateStats do + describe '#call' do + subject(:create_stats) { described_class.new(user_ids).call } + + let(:user_ids) { [user.id] } + let(:user) { create(:user) } + + context 'when there are no points' do + it 'does not create stats' do + expect { create_stats }.not_to change { Stat.count } + end + end + + context 'when there are points' do + let!(:import) { create(:import, user: user) } + let!(:point_1) { create(:point, import: import, latitude: 0, longitude: 0) } + let!(:point_2) { create(:point, import: import, latitude: 1, longitude: 2) } + let!(:point_3) { create(:point, import: import, latitude: 3, longitude: 4) } + + + it 'creates stats' do + expect { create_stats }.to change { Stat.count }.by(1) + end + + it 'calculates distance' do + create_stats + + expect(Stat.last.distance).to eq(563) + end + end + end +end From 0afea82aaeef1187a63fd8bfa78a1707a539ccfc Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 2 Apr 2024 23:31:42 +0200 Subject: [PATCH 4/5] Add spec for CountriesAndCities service --- spec/services/countries_and_cities_spec.rb | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 spec/services/countries_and_cities_spec.rb diff --git a/spec/services/countries_and_cities_spec.rb b/spec/services/countries_and_cities_spec.rb new file mode 100644 index 00000000..24d8ab3d --- /dev/null +++ b/spec/services/countries_and_cities_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe CountriesAndCities do + describe '#call' do + subject(:countries_and_cities) { described_class.new(points).call } + + let(:points) do + [ + create(:point, latitude: 0, longitude: 0, city: 'City', country: 'Country'), + create(:point, latitude: 1, longitude: 1, city: 'City', country: 'Country'), + create(:point, latitude: 2, longitude: 2, city: 'City', country: 'Country'), + create(:point, latitude: 2, longitude: 2, city: 'Another city', country: 'Some Country'), + create(:point, latitude: 2, longitude: 6, city: 'Another city', country: 'Some Country') + ] + end + + before do + stub_const('CountriesAndCities::MINIMUM_POINTS_IN_CITY', 1) + end + + it 'returns countries and cities' do + expect(countries_and_cities).to eq( + [ + { cities: [{city: "City", points: 3, timestamp: 1}], country: "Country" }, + { cities: [{city: "Another city", points: 2, timestamp: 1}], country: "Some Country" } + ] + ) + end + end +end From 904fdaf8be1da8717ae4cfc39bf35ee79df4b874 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 2 Apr 2024 23:33:03 +0200 Subject: [PATCH 5/5] Add spec for MINIMUM_POINTS_IN_CITY = 3 --- spec/services/countries_and_cities_spec.rb | 35 ++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/spec/services/countries_and_cities_spec.rb b/spec/services/countries_and_cities_spec.rb index 24d8ab3d..194b29ad 100644 --- a/spec/services/countries_and_cities_spec.rb +++ b/spec/services/countries_and_cities_spec.rb @@ -14,17 +14,34 @@ RSpec.describe CountriesAndCities do ] end - before do - stub_const('CountriesAndCities::MINIMUM_POINTS_IN_CITY', 1) + context 'when MINIMUM_POINTS_IN_CITY is 1' do + before do + stub_const('CountriesAndCities::MINIMUM_POINTS_IN_CITY', 1) + end + + it 'returns countries and cities' do + expect(countries_and_cities).to eq( + [ + { cities: [{city: "City", points: 3, timestamp: 1}], country: "Country" }, + { cities: [{city: "Another city", points: 2, timestamp: 1}], country: "Some Country" } + ] + ) + end end - it 'returns countries and cities' do - expect(countries_and_cities).to eq( - [ - { cities: [{city: "City", points: 3, timestamp: 1}], country: "Country" }, - { cities: [{city: "Another city", points: 2, timestamp: 1}], country: "Some Country" } - ] - ) + context 'when MINIMUM_POINTS_IN_CITY is 3' do + before do + stub_const('CountriesAndCities::MINIMUM_POINTS_IN_CITY', 3) + end + + it 'returns countries and cities' do + expect(countries_and_cities).to eq( + [ + { cities: [{city: "City", points: 3, timestamp: 1}], country: "Country" }, + { cities: [], country: "Some Country" } + ] + ) + end end end end