Compare commits

...

12 commits

Author SHA1 Message Date
Evgenii Burmakin
fc9544242e
Merge fbdf630502 into f5c399a8cc 2025-07-20 22:23:16 +02:00
Eugene Burmakin
fbdf630502 Update changelog 2025-07-20 22:23:08 +02:00
Eugene Burmakin
c74ba7d1fe Revert "Optimize bulk visits suggesting job" 2025-07-20 21:54:00 +02:00
Eugene Burmakin
6ec24ffc3d Optimize bulk visits suggesting job 2025-07-20 21:38:46 +02:00
Eugene Burmakin
b7aa05f4ea Fix specs 2025-07-20 21:29:05 +02:00
Eugene Burmakin
8b03b0c7f5 Recalculate stats after changing distance units 2025-07-20 19:14:20 +02:00
Eugene Burmakin
f969d5d3e6 Clean up some mess 2025-07-20 18:57:53 +02:00
Eugene Burmakin
708bca26eb Fix owntracks point creation 2025-07-20 17:43:55 +02:00
Eugene Burmakin
45713f46dc Fix domain in development and production 2025-07-20 17:31:31 +02:00
Evgenii Burmakin
3149767675
Merge pull request #1531 from Freika/fix/map-tracks-popup
Fix/map tracks popup
2025-07-20 17:31:26 +02:00
Eugene Burmakin
f5c399a8cc Fix domain in development and production 2025-07-20 17:11:11 +02:00
Eugene Burmakin
002b3bd635 Fix settings controller spec and tracks popup 2025-07-20 17:06:45 +02:00
37 changed files with 444 additions and 350 deletions

View file

@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# [0.29.2] - 2025-07-12
⚠️ If you were using RC, please run the following commands in the console, otherwise read on. ⚠️
```ruby
# This will delete all tracks 👇
Track.delete_all
# This will remove all tracks relations from points 👇
Point.update_all(track_id: nil)
# This will create tracks for all users 👇
User.find_each do |user|
Tracks::CreateJob.perform_later(user.id, start_at: nil, end_at: nil, mode: :bulk)
end
```
## Added
- In the User Settings -> Background Jobs, you can now disable visits suggestions, which is enabled by default. It's a background task that runs every day around midnight. Disabling it might be useful if you don't want to receive visits suggestions or if you're using the Dawarich iOS app, which has its own visits suggestions.
@ -52,10 +67,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
}
}
```
- Links in emails will be based on the `DOMAIN` environment variable instead of `SMTP_DOMAIN`.
## Fixed
- Swagger documentation is now valid again.
- Invalid owntracks points are now ignored.
# [0.29.1] - 2025-07-02

View file

@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
def index
render json: {
settings: current_api_user.settings,
settings: current_api_user.safe_settings,
status: 'success'
}, status: :ok
end

View file

@ -31,7 +31,8 @@ class MapController < ApplicationController
def build_tracks
track_ids = extract_track_ids
TrackSerializer.new(current_user, track_ids).call
TracksSerializer.new(current_user, track_ids).call
end
def calculate_distance

View file

@ -30,7 +30,7 @@ export function createTrackPopupContent(track, distanceUnit) {
<strong>🕐 Start:</strong> ${startTime}<br>
<strong>🏁 End:</strong> ${endTime}<br>
<strong> Duration:</strong> ${durationFormatted}<br>
<strong>📏 Distance:</strong> ${formatDistance(track.distance, distanceUnit)}<br>
<strong>📏 Distance:</strong> ${formatDistance(track.distance / 1000, distanceUnit)}<br>
<strong> Avg Speed:</strong> ${formatSpeed(track.avg_speed, distanceUnit)}<br>
<strong> Elevation:</strong> +${track.elevation_gain || 0}m / -${track.elevation_loss || 0}m<br>
<strong>📊 Max Alt:</strong> ${track.elevation_max || 0}m<br>

View file

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

View file

@ -8,7 +8,7 @@ class Owntracks::PointCreatingJob < ApplicationJob
def perform(point_params, user_id)
parsed_params = OwnTracks::Params.new(point_params).call
return if parsed_params[:timestamp].nil? || parsed_params[:lonlat].nil?
return if parsed_params.try(:[], :timestamp).nil? || parsed_params.try(:[], :lonlat).nil?
return if point_exists?(parsed_params, user_id)
Point.create!(parsed_params.merge(user_id:))

View file

@ -6,20 +6,7 @@ class Tracks::CreateJob < ApplicationJob
def perform(user_id, start_at: nil, end_at: nil, mode: :daily)
user = User.find(user_id)
# Translate mode parameter to Generator mode
generator_mode = case mode
when :daily then :daily
when :none then :incremental
else :bulk
end
# Generate tracks and get the count of tracks created
tracks_created = Tracks::Generator.new(
user,
start_at: start_at,
end_at: end_at,
mode: generator_mode
).call
tracks_created = Tracks::Generator.new(user, start_at:, end_at:, mode:).call
create_success_notification(user, tracks_created)
rescue StandardError => e

View file

@ -19,7 +19,6 @@
# track.distance # => 5000 (meters stored in DB)
# track.distance_in_unit('km') # => 5.0 (converted to km)
# track.distance_in_unit('mi') # => 3.11 (converted to miles)
# track.formatted_distance('km') # => "5.0 km"
#
module DistanceConvertible
extend ActiveSupport::Concern
@ -38,21 +37,11 @@ module DistanceConvertible
distance.to_f / conversion_factor
end
def formatted_distance(unit, precision: 2)
converted_distance = distance_in_unit(unit)
"#{converted_distance.round(precision)} #{unit}"
end
def distance_for_user(user)
user_unit = user.safe_settings.distance_unit
distance_in_unit(user_unit)
end
def formatted_distance_for_user(user, precision: 2)
user_unit = user.safe_settings.distance_unit
formatted_distance(user_unit, precision: precision)
end
module ClassMethods
def convert_distance(distance_meters, unit)
return 0.0 unless distance_meters.present?
@ -66,10 +55,5 @@ module DistanceConvertible
distance_meters.to_f / conversion_factor
end
def format_distance(distance_meters, unit, precision: 2)
converted = convert_distance(distance_meters, unit)
"#{converted.round(precision)} #{unit}"
end
end
end

View file

@ -34,7 +34,7 @@ class Point < ApplicationRecord
after_create :set_country
after_create_commit :broadcast_coordinates
after_create_commit :trigger_incremental_track_generation, if: -> { import_id.nil? }
after_commit :recalculate_track, on: :update
after_commit :recalculate_track, on: :update, if: -> { track.present? }
def self.without_raw_data
select(column_names - ['raw_data'])
@ -99,8 +99,6 @@ class Point < ApplicationRecord
end
def recalculate_track
return unless track.present?
track.recalculate_path_and_distance!
end

View file

@ -1,38 +1,23 @@
# frozen_string_literal: true
class TrackSerializer
def initialize(user, track_ids)
@user = user
@track_ids = track_ids
def initialize(track)
@track = track
end
def call
return [] if track_ids.empty?
tracks = user.tracks
.where(id: track_ids)
.order(start_at: :asc)
tracks.map { |track| serialize_track_data(track) }
end
private
attr_reader :user, :track_ids
def serialize_track_data(track)
{
id: track.id,
start_at: track.start_at.iso8601,
end_at: track.end_at.iso8601,
distance: track.distance.to_i,
avg_speed: track.avg_speed.to_f,
duration: track.duration,
elevation_gain: track.elevation_gain,
elevation_loss: track.elevation_loss,
elevation_max: track.elevation_max,
elevation_min: track.elevation_min,
original_path: track.original_path.to_s
id: @track.id,
start_at: @track.start_at.iso8601,
end_at: @track.end_at.iso8601,
distance: @track.distance.to_i,
avg_speed: @track.avg_speed.to_f,
duration: @track.duration,
elevation_gain: @track.elevation_gain,
elevation_loss: @track.elevation_loss,
elevation_max: @track.elevation_max,
elevation_min: @track.elevation_min,
original_path: @track.original_path.to_s
}
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class TracksSerializer
def initialize(user, track_ids)
@user = user
@track_ids = track_ids
end
def call
return [] if track_ids.empty?
tracks = user.tracks
.where(id: track_ids)
.order(start_at: :asc)
tracks.map { |track| TrackSerializer.new(track).call }
end
private
attr_reader :user, :track_ids
end

View file

@ -10,6 +10,8 @@ class OwnTracks::Params
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/AbcSize
def call
return unless valid_point?
{
lonlat: "POINT(#{params[:lon]} #{params[:lat]})",
battery: params[:batt],
@ -84,4 +86,8 @@ class OwnTracks::Params
def owntracks_point?
params[:topic].present?
end
def valid_point?
params[:lon].present? && params[:lat].present? && params[:tst].present?
end
end

View file

@ -7,7 +7,7 @@ module Places
end
def call
geodata = Geocoder.search([@place.lat, @place.lon], units: :km, limit: 1, distance_sort: true).first
geodata = Geocoder.search([place.lat, place.lon], units: :km, limit: 1, distance_sort: true).first
return if geodata.blank?
@ -15,21 +15,29 @@ module Places
return if properties.blank?
ActiveRecord::Base.transaction do
@place.name = properties['name'] if properties['name'].present?
@place.city = properties['city'] if properties['city'].present?
@place.country = properties['country'] if properties['country'].present?
@place.geodata = geodata.data if DawarichSettings.store_geodata?
@place.save!
update_place_name(properties, geodata)
if properties['name'].present?
@place
.visits
.where(name: Place::DEFAULT_NAME)
.update_all(name: properties['name'])
update_visits_name(properties) if properties['name'].present?
place
end
end
@place
private
attr_reader :place
def update_place_name(properties, geodata)
place.name = properties['name'] if properties['name'].present?
place.city = properties['city'] if properties['city'].present?
place.country = properties['country'] if properties['country'].present?
place.geodata = geodata.data if DawarichSettings.store_geodata?
place.save!
end
def update_visits_name(properties)
place.visits.where(name: Place::DEFAULT_NAME).update_all(name: properties['name'])
end
end
end

View file

@ -48,6 +48,7 @@ class Tracks::Generator
Rails.logger.debug "Generator: created #{segments.size} segments"
tracks_created = 0
segments.each do |segment|
track = create_track_from_segment(segment)
tracks_created += 1 if track
@ -146,10 +147,6 @@ class Tracks::Generator
day.beginning_of_day.to_i..day.end_of_day.to_i
end
def incremental_mode?
mode == :incremental
end
def clean_existing_tracks
case mode
when :bulk then clean_bulk_tracks

View file

@ -36,12 +36,7 @@ class Tracks::IncrementalProcessor
start_at = find_start_time
end_at = find_end_time
Tracks::CreateJob.perform_later(
user.id,
start_at: start_at,
end_at: end_at,
mode: :none
)
Tracks::CreateJob.perform_later(user.id, start_at:, end_at:, mode: :incremental)
end
private

View file

@ -77,7 +77,7 @@ module Tracks::Segmentation
return true if time_diff_seconds > time_threshold_seconds
# Check distance threshold - convert km to meters to match frontend logic
distance_km = calculate_distance_kilometers_between_points(previous_point, current_point)
distance_km = calculate_km_distance_between_points(previous_point, current_point)
distance_meters = distance_km * 1000 # Convert km to meters
return true if distance_meters > distance_threshold_meters
@ -85,7 +85,7 @@ module Tracks::Segmentation
false
end
def calculate_distance_kilometers_between_points(point1, point2)
def calculate_km_distance_between_points(point1, point2)
lat1, lon1 = point_coordinates(point1)
lat2, lon2 = point_coordinates(point2)

View file

@ -113,7 +113,6 @@ class Users::SafeSettings
end
def distance_unit
# km or mi
settings.dig('maps', 'distance_unit')
end

View file

@ -88,7 +88,7 @@ Rails.application.configure do
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
config.action_mailer.default_url_options = { host: ENV['SMTP_DOMAIN'] || hosts.first, port: ENV.fetch('PORT', 3000) }
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] || hosts.first, port: ENV.fetch('PORT', 3000) }
config.hosts.concat(hosts) if hosts.present?

View file

@ -103,7 +103,7 @@ Rails.application.configure do
config.host_authorization = { exclude: ->(request) { request.path == "/api/v1/health" } }
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
config.action_mailer.default_url_options = { host: ENV['SMTP_DOMAIN'] }
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] }
config.hosts.concat(hosts) if hosts.present?
config.action_mailer.delivery_method = :smtp

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class RecalculateStatsAfterChangingDistanceUnits < ActiveRecord::Migration[8.0]
def up
BulkStatsCalculatingJob.perform_later
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20250709195003)
DataMigrate::Data.define(version: 20250720171241)

View file

@ -13,7 +13,7 @@ FactoryBot.define do
settings do
{
'route_opacity' => '0.5',
'route_opacity' => '50',
'meters_between_routes' => '500',
'minutes_between_routes' => '30',
'fog_of_war_meters' => '100',

View file

@ -4,13 +4,13 @@ require 'rails_helper'
RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do
describe '#perform' do
let!(:user) { create(:user) }
let!(:area) { create(:area, user: user) }
let(:user) { create(:user) }
let(:area) { create(:area, user: user) }
it 'calls the AreaVisitsCalculationService' do
expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id)
expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original
described_class.new.perform_now
described_class.new.perform
end
end
end

View file

@ -28,5 +28,13 @@ RSpec.describe Owntracks::PointCreatingJob, type: :job do
expect { perform }.not_to(change { Point.count })
end
end
context 'when point is invalid' do
let(:point_params) { { lat: 1.0, lon: 1.0, tid: 'test', tst: nil, topic: 'iPhone 12 pro' } }
it 'does not create a point' do
expect { perform }.not_to(change { Point.count })
end
end
end
end

View file

@ -14,12 +14,10 @@ RSpec.describe Tracks::CreateJob, type: :job do
allow(generator_instance).to receive(:call)
allow(Notifications::Create).to receive(:new).and_return(notification_service)
allow(notification_service).to receive(:call)
allow(generator_instance).to receive(:call).and_return(2)
end
it 'calls the generator and creates a notification' do
# Mock the generator to return the count of tracks created
allow(generator_instance).to receive(:call).and_return(2)
described_class.new.perform(user.id)
expect(Tracks::Generator).to have_received(:new).with(
@ -48,12 +46,10 @@ RSpec.describe Tracks::CreateJob, type: :job do
allow(generator_instance).to receive(:call)
allow(Notifications::Create).to receive(:new).and_return(notification_service)
allow(notification_service).to receive(:call)
allow(generator_instance).to receive(:call).and_return(1)
end
it 'passes custom parameters to the generator' do
# Mock generator to return the count of tracks created
allow(generator_instance).to receive(:call).and_return(1)
described_class.new.perform(user.id, start_at: start_at, end_at: end_at, mode: mode)
expect(Tracks::Generator).to have_received(:new).with(
@ -73,72 +69,6 @@ RSpec.describe Tracks::CreateJob, type: :job do
end
end
context 'with mode translation' do
before do
allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
allow(generator_instance).to receive(:call) # No tracks created for mode tests
allow(Notifications::Create).to receive(:new).and_return(notification_service)
allow(notification_service).to receive(:call)
end
it 'translates :none to :incremental' do
allow(generator_instance).to receive(:call).and_return(0)
described_class.new.perform(user.id, mode: :none)
expect(Tracks::Generator).to have_received(:new).with(
user,
start_at: nil,
end_at: nil,
mode: :incremental
)
expect(Notifications::Create).to have_received(:new).with(
user: user,
kind: :info,
title: 'Tracks Generated',
content: 'Created 0 tracks from your location data. Check your tracks section to view them.'
)
end
it 'translates :daily to :daily' do
allow(generator_instance).to receive(:call).and_return(0)
described_class.new.perform(user.id, mode: :daily)
expect(Tracks::Generator).to have_received(:new).with(
user,
start_at: nil,
end_at: nil,
mode: :daily
)
expect(Notifications::Create).to have_received(:new).with(
user: user,
kind: :info,
title: 'Tracks Generated',
content: 'Created 0 tracks from your location data. Check your tracks section to view them.'
)
end
it 'translates other modes to :bulk' do
allow(generator_instance).to receive(:call).and_return(0)
described_class.new.perform(user.id, mode: :replace)
expect(Tracks::Generator).to have_received(:new).with(
user,
start_at: nil,
end_at: nil,
mode: :bulk
)
expect(Notifications::Create).to have_received(:new).with(
user: user,
kind: :info,
title: 'Tracks Generated',
content: 'Created 0 tracks from your location data. Check your tracks section to view them.'
)
end
end
context 'when generator raises an error' do
let(:error_message) { 'Something went wrong' }
let(:notification_service) { instance_double(Notifications::Create) }
@ -175,12 +105,13 @@ RSpec.describe Tracks::CreateJob, type: :job do
end
context 'when user does not exist' do
it 'handles the error gracefully and creates error notification' do
before do
allow(User).to receive(:find).with(999).and_raise(ActiveRecord::RecordNotFound)
allow(ExceptionReporter).to receive(:call)
allow(Notifications::Create).to receive(:new).and_return(instance_double(Notifications::Create, call: nil))
end
# Should not raise an error because it's caught by the rescue block
it 'handles the error gracefully and creates error notification' do
expect { described_class.new.perform(999) }.not_to raise_error
expect(ExceptionReporter).to have_received(:call)
@ -188,21 +119,20 @@ RSpec.describe Tracks::CreateJob, type: :job do
end
context 'when tracks are deleted and recreated' do
it 'returns the correct count of newly created tracks' do
# Create some existing tracks first
create_list(:track, 3, user: user)
let(:existing_tracks) { create_list(:track, 3, user: user) }
# Mock the generator to simulate deleting existing tracks and creating new ones
# This should return the count of newly created tracks, not the difference
before do
allow(generator_instance).to receive(:call).and_return(2)
end
described_class.new.perform(user.id, mode: :bulk)
it 'returns the correct count of newly created tracks' do
described_class.new.perform(user.id, mode: :incremental)
expect(Tracks::Generator).to have_received(:new).with(
user,
start_at: nil,
end_at: nil,
mode: :bulk
mode: :incremental
)
expect(generator_instance).to have_received(:call)
expect(Notifications::Create).to have_received(:new).with(

View file

@ -41,9 +41,6 @@ RSpec.configure do |config|
config.before(:suite) do
Rails.application.reload_routes!
# DatabaseCleaner.strategy = :transaction
# DatabaseCleaner.clean_with(:truncation)
end
config.before do
@ -92,12 +89,6 @@ RSpec.configure do |config|
config.after(:suite) do
Rake::Task['rswag:generate'].invoke
end
# config.around(:each) do |example|
# DatabaseCleaner.cleaning do
# example.run
# end
# end
end
Shoulda::Matchers.configure do |config|

View file

@ -5,95 +5,166 @@ require 'rails_helper'
RSpec.describe TrackSerializer do
describe '#call' do
let(:user) { create(:user) }
let(:track) { create(:track, user: user) }
let(:serializer) { described_class.new(track) }
context 'when serializing user tracks with track IDs' do
subject(:serializer) { described_class.new(user, track_ids).call }
subject(:serialized_track) { serializer.call }
let!(:track1) { create(:track, user: user, start_at: 2.hours.ago, end_at: 1.hour.ago) }
let!(:track2) { create(:track, user: user, start_at: 4.hours.ago, end_at: 3.hours.ago) }
let!(:track3) { create(:track, user: user, start_at: 6.hours.ago, end_at: 5.hours.ago) }
let(:track_ids) { [track1.id, track2.id] }
it 'returns an array of serialized tracks' do
expect(serializer).to be_an(Array)
expect(serializer.length).to eq(2)
end
it 'serializes each track correctly' do
serialized_ids = serializer.map { |track| track[:id] }
expect(serialized_ids).to contain_exactly(track1.id, track2.id)
expect(serialized_ids).not_to include(track3.id)
end
it 'formats timestamps as ISO8601 for all tracks' do
serializer.each do |track|
expect(track[:start_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
expect(track[:end_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
end
end
it 'includes all required fields for each track' do
serializer.each do |track|
expect(track.keys).to contain_exactly(
it 'returns a hash with all required attributes' do
expect(serialized_track).to be_a(Hash)
expect(serialized_track.keys).to contain_exactly(
:id, :start_at, :end_at, :distance, :avg_speed, :duration,
:elevation_gain, :elevation_loss, :elevation_max, :elevation_min, :original_path
)
end
it 'serializes the track ID correctly' do
expect(serialized_track[:id]).to eq(track.id)
end
it 'handles numeric values correctly' do
serializer.each do |track|
expect(track[:distance]).to be_a(Numeric)
expect(track[:avg_speed]).to be_a(Numeric)
expect(track[:duration]).to be_a(Numeric)
expect(track[:elevation_gain]).to be_a(Numeric)
expect(track[:elevation_loss]).to be_a(Numeric)
expect(track[:elevation_max]).to be_a(Numeric)
expect(track[:elevation_min]).to be_a(Numeric)
it 'formats start_at as ISO8601 timestamp' do
expect(serialized_track[:start_at]).to eq(track.start_at.iso8601)
expect(serialized_track[:start_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
end
it 'formats end_at as ISO8601 timestamp' do
expect(serialized_track[:end_at]).to eq(track.end_at.iso8601)
expect(serialized_track[:end_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
end
it 'converts distance to integer' do
expect(serialized_track[:distance]).to eq(track.distance.to_i)
expect(serialized_track[:distance]).to be_a(Integer)
end
it 'converts avg_speed to float' do
expect(serialized_track[:avg_speed]).to eq(track.avg_speed.to_f)
expect(serialized_track[:avg_speed]).to be_a(Float)
end
it 'serializes duration as numeric value' do
expect(serialized_track[:duration]).to eq(track.duration)
expect(serialized_track[:duration]).to be_a(Numeric)
end
it 'serializes elevation_gain as numeric value' do
expect(serialized_track[:elevation_gain]).to eq(track.elevation_gain)
expect(serialized_track[:elevation_gain]).to be_a(Numeric)
end
it 'serializes elevation_loss as numeric value' do
expect(serialized_track[:elevation_loss]).to eq(track.elevation_loss)
expect(serialized_track[:elevation_loss]).to be_a(Numeric)
end
it 'serializes elevation_max as numeric value' do
expect(serialized_track[:elevation_max]).to eq(track.elevation_max)
expect(serialized_track[:elevation_max]).to be_a(Numeric)
end
it 'serializes elevation_min as numeric value' do
expect(serialized_track[:elevation_min]).to eq(track.elevation_min)
expect(serialized_track[:elevation_min]).to be_a(Numeric)
end
it 'converts original_path to string' do
expect(serialized_track[:original_path]).to eq(track.original_path.to_s)
expect(serialized_track[:original_path]).to be_a(String)
end
context 'with decimal distance values' do
let(:track) { create(:track, user: user, distance: 1234.56) }
it 'truncates distance to integer' do
expect(serialized_track[:distance]).to eq(1234)
end
end
it 'orders tracks by start_at in ascending order' do
serialized_tracks = serializer
expect(serialized_tracks.first[:id]).to eq(track2.id) # Started 4 hours ago
expect(serialized_tracks.second[:id]).to eq(track1.id) # Started 2 hours ago
context 'with decimal avg_speed values' do
let(:track) { create(:track, user: user, avg_speed: 25.75) }
it 'converts avg_speed to float' do
expect(serialized_track[:avg_speed]).to eq(25.75)
end
end
context 'when track IDs belong to different users' do
subject(:serializer) { described_class.new(user, track_ids).call }
context 'with different original_path formats' do
let(:track) { create(:track, user: user, original_path: 'LINESTRING(0 0, 1 1, 2 2)') }
let(:other_user) { create(:user) }
let!(:user_track) { create(:track, user: user) }
let!(:other_user_track) { create(:track, user: other_user) }
let(:track_ids) { [user_track.id, other_user_track.id] }
it 'only returns tracks belonging to the specified user' do
serialized_ids = serializer.map { |track| track[:id] }
expect(serialized_ids).to contain_exactly(user_track.id)
expect(serialized_ids).not_to include(other_user_track.id)
it 'converts geometry to WKT string format' do
expect(serialized_track[:original_path]).to eq('LINESTRING (0 0, 1 1, 2 2)')
expect(serialized_track[:original_path]).to be_a(String)
end
end
context 'when track IDs array is empty' do
subject(:serializer) { described_class.new(user, []).call }
context 'with zero values' do
let(:track) do
create(:track, user: user,
distance: 0,
avg_speed: 0.0,
duration: 0,
elevation_gain: 0,
elevation_loss: 0,
elevation_max: 0,
elevation_min: 0)
end
it 'returns an empty array' do
expect(serializer).to eq([])
it 'handles zero values correctly' do
expect(serialized_track[:distance]).to eq(0)
expect(serialized_track[:avg_speed]).to eq(0.0)
expect(serialized_track[:duration]).to eq(0)
expect(serialized_track[:elevation_gain]).to eq(0)
expect(serialized_track[:elevation_loss]).to eq(0)
expect(serialized_track[:elevation_max]).to eq(0)
expect(serialized_track[:elevation_min]).to eq(0)
end
end
context 'when track IDs contain non-existent IDs' do
subject(:serializer) { described_class.new(user, track_ids).call }
let!(:existing_track) { create(:track, user: user) }
let(:track_ids) { [existing_track.id, 999999] }
it 'only returns existing tracks' do
serialized_ids = serializer.map { |track| track[:id] }
expect(serialized_ids).to contain_exactly(existing_track.id)
expect(serializer.length).to eq(1)
context 'with very large values' do
let(:track) do
create(:track, user: user,
distance: 1_000_000.0,
avg_speed: 999.99,
duration: 86_400, # 24 hours in seconds
elevation_gain: 10_000,
elevation_loss: 8_000,
elevation_max: 5_000,
elevation_min: 0)
end
it 'handles large values correctly' do
expect(serialized_track[:distance]).to eq(1_000_000)
expect(serialized_track[:avg_speed]).to eq(999.99)
expect(serialized_track[:duration]).to eq(86_400)
expect(serialized_track[:elevation_gain]).to eq(10_000)
expect(serialized_track[:elevation_loss]).to eq(8_000)
expect(serialized_track[:elevation_max]).to eq(5_000)
expect(serialized_track[:elevation_min]).to eq(0)
end
end
context 'with different timestamp formats' do
let(:start_time) { Time.current }
let(:end_time) { start_time + 1.hour }
let(:track) { create(:track, user: user, start_at: start_time, end_at: end_time) }
it 'formats timestamps consistently' do
expect(serialized_track[:start_at]).to eq(start_time.iso8601)
expect(serialized_track[:end_at]).to eq(end_time.iso8601)
end
end
end
describe '#initialize' do
let(:track) { create(:track) }
it 'accepts a track parameter' do
expect { described_class.new(track) }.not_to raise_error
end
it 'stores the track instance' do
serializer = described_class.new(track)
expect(serializer.instance_variable_get(:@track)).to eq(track)
end
end
end

View file

@ -0,0 +1,99 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TracksSerializer do
describe '#call' do
let(:user) { create(:user) }
context 'when serializing user tracks with track IDs' do
subject(:serializer) { described_class.new(user, track_ids).call }
let!(:track1) { create(:track, user: user, start_at: 2.hours.ago, end_at: 1.hour.ago) }
let!(:track2) { create(:track, user: user, start_at: 4.hours.ago, end_at: 3.hours.ago) }
let!(:track3) { create(:track, user: user, start_at: 6.hours.ago, end_at: 5.hours.ago) }
let(:track_ids) { [track1.id, track2.id] }
it 'returns an array of serialized tracks' do
expect(serializer).to be_an(Array)
expect(serializer.length).to eq(2)
end
it 'serializes each track correctly' do
serialized_ids = serializer.map { |track| track[:id] }
expect(serialized_ids).to contain_exactly(track1.id, track2.id)
expect(serialized_ids).not_to include(track3.id)
end
it 'formats timestamps as ISO8601 for all tracks' do
serializer.each do |track|
expect(track[:start_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
expect(track[:end_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
end
end
it 'includes all required fields for each track' do
serializer.each do |track|
expect(track.keys).to contain_exactly(
:id, :start_at, :end_at, :distance, :avg_speed, :duration,
:elevation_gain, :elevation_loss, :elevation_max, :elevation_min, :original_path
)
end
end
it 'handles numeric values correctly' do
serializer.each do |track|
expect(track[:distance]).to be_a(Numeric)
expect(track[:avg_speed]).to be_a(Numeric)
expect(track[:duration]).to be_a(Numeric)
expect(track[:elevation_gain]).to be_a(Numeric)
expect(track[:elevation_loss]).to be_a(Numeric)
expect(track[:elevation_max]).to be_a(Numeric)
expect(track[:elevation_min]).to be_a(Numeric)
end
end
it 'orders tracks by start_at in ascending order' do
serialized_tracks = serializer
expect(serialized_tracks.first[:id]).to eq(track2.id) # Started 4 hours ago
expect(serialized_tracks.second[:id]).to eq(track1.id) # Started 2 hours ago
end
end
context 'when track IDs belong to different users' do
subject(:serializer) { described_class.new(user, track_ids).call }
let(:other_user) { create(:user) }
let!(:user_track) { create(:track, user: user) }
let!(:other_user_track) { create(:track, user: other_user) }
let(:track_ids) { [user_track.id, other_user_track.id] }
it 'only returns tracks belonging to the specified user' do
serialized_ids = serializer.map { |track| track[:id] }
expect(serialized_ids).to contain_exactly(user_track.id)
expect(serialized_ids).not_to include(other_user_track.id)
end
end
context 'when track IDs array is empty' do
subject(:serializer) { described_class.new(user, []).call }
it 'returns an empty array' do
expect(serializer).to eq([])
end
end
context 'when track IDs contain non-existent IDs' do
subject(:serializer) { described_class.new(user, track_ids).call }
let!(:existing_track) { create(:track, user: user) }
let(:track_ids) { [existing_track.id, 999999] }
it 'only returns existing tracks' do
serialized_ids = serializer.map { |track| track[:id] }
expect(serialized_ids).to contain_exactly(existing_track.id)
expect(serializer.length).to eq(1)
end
end
end
end

View file

@ -185,5 +185,13 @@ RSpec.describe OwnTracks::Params do
expect(params[:trigger]).to eq('unknown')
end
end
context 'when point is invalid' do
let(:raw_point_params) { super().merge(lon: nil, lat: nil, tst: nil) }
it 'returns parsed params' do
expect(params).to eq(nil)
end
end
end
end

View file

@ -30,7 +30,7 @@ RSpec.describe Tracks::IncrementalProcessor do
it 'processes first point' do
expect(Tracks::CreateJob).to receive(:perform_later)
.with(user.id, start_at: nil, end_at: nil, mode: :none)
.with(user.id, start_at: nil, end_at: nil, mode: :incremental)
processor.call
end
end
@ -47,7 +47,7 @@ RSpec.describe Tracks::IncrementalProcessor do
it 'processes when time threshold exceeded' do
expect(Tracks::CreateJob).to receive(:perform_later)
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :none)
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
processor.call
end
end
@ -65,7 +65,7 @@ RSpec.describe Tracks::IncrementalProcessor do
it 'uses existing track end time as start_at' do
expect(Tracks::CreateJob).to receive(:perform_later)
.with(user.id, start_at: existing_track.end_at, end_at: Time.zone.at(previous_point.timestamp), mode: :none)
.with(user.id, start_at: existing_track.end_at, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
processor.call
end
end
@ -88,7 +88,7 @@ RSpec.describe Tracks::IncrementalProcessor do
it 'processes when distance threshold exceeded' do
expect(Tracks::CreateJob).to receive(:perform_later)
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :none)
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
processor.call
end
end

View file

@ -75,7 +75,6 @@ RSpec.describe Visits::Suggest do
end
context 'when reverse geocoding is enabled' do
# Use a different time range to avoid interference with main tests
let(:reverse_geocoding_start_at) { Time.zone.local(2020, 6, 1, 0, 0, 0) }
let(:reverse_geocoding_end_at) { Time.zone.local(2020, 6, 1, 2, 0, 0) }

View file

@ -2,7 +2,6 @@
RSpec.configure do |config|
config.before(:each) do
# Clear the cache before each test
Rails.cache.clear
end
end

View file

@ -21,8 +21,8 @@ describe 'Settings API', type: :request do
'immich_api_key': 'your-immich-api-key',
'photoprism_url': 'https://photoprism.example.com',
'photoprism_api_key': 'your-photoprism-api-key',
'maps': { 'distance_unit': 'km' },
'visits_suggestions_enabled': true
'speed_color_scale': 'viridis',
'fog_of_war_threshold': 100
}
}
tags 'Settings'
@ -100,21 +100,15 @@ describe 'Settings API', type: :request do
example: 'your-photoprism-api-key',
description: 'API key for PhotoPrism photo service'
},
maps: {
type: :object,
properties: {
distance_unit: {
speed_color_scale: {
type: :string,
example: 'km',
description: 'Distance unit preference (km or miles)'
}
example: 'viridis',
description: 'Color scale for speed-colored routes'
},
description: 'Map-related settings'
},
visits_suggestions_enabled: {
type: :boolean,
example: true,
description: 'Whether visit suggestions are enabled'
fog_of_war_threshold: {
type: :number,
example: 100,
description: 'Fog of war threshold value'
}
}
}
@ -138,33 +132,33 @@ describe 'Settings API', type: :request do
type: :object,
properties: {
route_opacity: {
type: :string,
example: '60',
type: :number,
example: 60,
description: 'Route opacity percentage (0-100)'
},
meters_between_routes: {
type: :string,
example: '500',
type: :number,
example: 500,
description: 'Minimum distance between routes in meters'
},
minutes_between_routes: {
type: :string,
example: '30',
type: :number,
example: 30,
description: 'Minimum time between routes in minutes'
},
fog_of_war_meters: {
type: :string,
example: '50',
type: :number,
example: 50,
description: 'Fog of war radius in meters'
},
time_threshold_minutes: {
type: :string,
example: '30',
type: :number,
example: 30,
description: 'Time threshold for grouping points in minutes'
},
merge_threshold_minutes: {
type: :string,
example: '15',
type: :number,
example: 15,
description: 'Threshold for merging nearby points in minutes'
},
preferred_map_layer: {
@ -207,21 +201,15 @@ describe 'Settings API', type: :request do
example: 'your-photoprism-api-key',
description: 'API key for PhotoPrism photo service'
},
maps: {
type: :object,
properties: {
distance_unit: {
speed_color_scale: {
type: :string,
example: 'km',
description: 'Distance unit preference (km or miles)'
}
example: 'viridis',
description: 'Color scale for speed-colored routes'
},
description: 'Map-related settings'
},
visits_suggestions_enabled: {
type: :boolean,
example: true,
description: 'Whether visit suggestions are enabled'
fog_of_war_threshold: {
type: :number,
example: 100,
description: 'Fog of war threshold value'
}
}
}

View file

@ -1059,18 +1059,14 @@ paths:
type: string
example: your-photoprism-api-key
description: API key for PhotoPrism photo service
maps:
type: object
properties:
distance_unit:
speed_color_scale:
type: string
example: km
description: Distance unit preference (km or miles)
description: Map-related settings
visits_suggestions_enabled:
type: boolean
example: true
description: Whether visit suggestions are enabled
example: viridis
description: Color scale for speed-colored routes
fog_of_war_threshold:
type: number
example: 100
description: Fog of war threshold value
examples:
'0':
summary: Updates user settings
@ -1090,9 +1086,8 @@ paths:
immich_api_key: your-immich-api-key
photoprism_url: https://photoprism.example.com
photoprism_api_key: your-photoprism-api-key
maps:
distance_unit: km
visits_suggestions_enabled: true
speed_color_scale: viridis
fog_of_war_threshold: 100
get:
summary: Retrieves user settings
tags:
@ -1116,28 +1111,28 @@ paths:
type: object
properties:
route_opacity:
type: string
example: '60'
type: number
example: 60
description: Route opacity percentage (0-100)
meters_between_routes:
type: string
example: '500'
type: number
example: 500
description: Minimum distance between routes in meters
minutes_between_routes:
type: string
example: '30'
type: number
example: 30
description: Minimum time between routes in minutes
fog_of_war_meters:
type: string
example: '50'
type: number
example: 50
description: Fog of war radius in meters
time_threshold_minutes:
type: string
example: '30'
type: number
example: 30
description: Time threshold for grouping points in minutes
merge_threshold_minutes:
type: string
example: '15'
type: number
example: 15
description: Threshold for merging nearby points in minutes
preferred_map_layer:
type: string
@ -1172,18 +1167,14 @@ paths:
type: string
example: your-photoprism-api-key
description: API key for PhotoPrism photo service
maps:
type: object
properties:
distance_unit:
speed_color_scale:
type: string
example: km
description: Distance unit preference (km or miles)
description: Map-related settings
visits_suggestions_enabled:
type: boolean
example: true
description: Whether visit suggestions are enabled
example: viridis
description: Color scale for speed-colored routes
fog_of_war_threshold:
type: number
example: 100
description: Fog of war threshold value
"/api/v1/stats":
get:
summary: Retrieves all stats