mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Merge pull request #699 from Freika/feature/api/points
This commit is contained in:
commit
8bf69e1e36
38 changed files with 687 additions and 47 deletions
|
|
@ -1 +1 @@
|
|||
0.22.5
|
||||
0.23.0
|
||||
|
|
|
|||
|
|
@ -5,10 +5,16 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# 0.22.5 - 2025-01-20
|
||||
# 0.23.0 - 2025-01-20
|
||||
|
||||
## ⚠️ IMPORTANT ⚠️
|
||||
|
||||
This release includes a data migration to remove duplicated points from the database. It will not remove anything except for duplcates from the `points` table, but please make sure to create a [backup](https://dawarich.app/docs/tutorials/backup-and-restore) before updating to this version.
|
||||
|
||||
### Added
|
||||
|
||||
- `POST /api/v1/points/create` endpoint added.
|
||||
- An index to guarantee uniqueness of points across `latitude`, `longitude`, `timestamp` and `user_id` values. This is introduced to make sure no duplicates will be created in the database in addition to previously existing validations.
|
||||
- `GET /api/v1/users/me` endpoint added to get current user.
|
||||
|
||||
# 0.22.4 - 2025-01-20
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ class Api::V1::PointsController < ApiController
|
|||
render json: serialized_points
|
||||
end
|
||||
|
||||
def create
|
||||
Points::CreateJob.perform_later(batch_params, current_api_user.id)
|
||||
|
||||
render json: { message: 'Points are being processed' }
|
||||
end
|
||||
|
||||
def update
|
||||
point = current_api_user.tracked_points.find(params[:id])
|
||||
|
||||
|
|
@ -42,6 +48,10 @@ class Api::V1::PointsController < ApiController
|
|||
params.require(:point).permit(:latitude, :longitude)
|
||||
end
|
||||
|
||||
def batch_params
|
||||
params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {})
|
||||
end
|
||||
|
||||
def point_serializer
|
||||
params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ class MapController < ApplicationController
|
|||
def index
|
||||
@points = points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
|
||||
|
||||
@countries_and_cities = CountriesAndCities.new(@points).call
|
||||
@coordinates =
|
||||
@points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, :country)
|
||||
.map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
|
||||
|
|
|
|||
17
app/jobs/points/create_job.rb
Normal file
17
app/jobs/points/create_job.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Points::CreateJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(params, user_id)
|
||||
data = Points::Params.new(params, user_id).call
|
||||
|
||||
data.each_slice(1000) do |location_batch|
|
||||
Point.upsert_all(
|
||||
location_batch,
|
||||
unique_by: %i[latitude longitude timestamp user_id],
|
||||
returning: false
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -8,7 +8,11 @@ class Point < ApplicationRecord
|
|||
belongs_to :user
|
||||
|
||||
validates :latitude, :longitude, :timestamp, presence: true
|
||||
|
||||
validates :timestamp, uniqueness: {
|
||||
scope: %i[latitude longitude user_id],
|
||||
message: 'already has a point at this location and time for this user',
|
||||
index: true
|
||||
}
|
||||
enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3 }, suffix: true
|
||||
enum :trigger, {
|
||||
unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3,
|
||||
|
|
|
|||
49
app/services/points/params.rb
Normal file
49
app/services/points/params.rb
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Points::Params
|
||||
attr_reader :data, :points, :user_id
|
||||
|
||||
def initialize(json, user_id)
|
||||
@data = json.with_indifferent_access
|
||||
@points = @data[:locations]
|
||||
@user_id = user_id
|
||||
end
|
||||
|
||||
def call
|
||||
points.map do |point|
|
||||
next unless params_valid?(point)
|
||||
|
||||
{
|
||||
latitude: point[:geometry][:coordinates][1],
|
||||
longitude: point[:geometry][:coordinates][0],
|
||||
battery_status: point[:properties][:battery_state],
|
||||
battery: battery_level(point[:properties][:battery_level]),
|
||||
timestamp: DateTime.parse(point[:properties][:timestamp]),
|
||||
altitude: point[:properties][:altitude],
|
||||
tracker_id: point[:properties][:device_id],
|
||||
velocity: point[:properties][:speed],
|
||||
ssid: point[:properties][:wifi],
|
||||
accuracy: point[:properties][:horizontal_accuracy],
|
||||
vertical_accuracy: point[:properties][:vertical_accuracy],
|
||||
course_accuracy: point[:properties][:course_accuracy],
|
||||
course: point[:properties][:course],
|
||||
raw_data: point,
|
||||
user_id: user_id
|
||||
}
|
||||
end.compact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def battery_level(level)
|
||||
value = (level.to_f * 100).to_i
|
||||
|
||||
value.positive? ? value : nil
|
||||
end
|
||||
|
||||
def params_valid?(point)
|
||||
point[:geometry].present? &&
|
||||
point[:geometry][:coordinates].present? &&
|
||||
point.dig(:properties, :timestamp).present?
|
||||
end
|
||||
end
|
||||
|
|
@ -68,7 +68,7 @@ Rails.application.routes.draw do
|
|||
get 'users/me', to: 'users#me'
|
||||
|
||||
resources :areas, only: %i[index create update destroy]
|
||||
resources :points, only: %i[index destroy update]
|
||||
resources :points, only: %i[index create update destroy]
|
||||
resources :visits, only: %i[update]
|
||||
resources :stats, only: :index
|
||||
|
||||
|
|
|
|||
31
db/data/20250120154554_remove_duplicate_points.rb
Normal file
31
db/data/20250120154554_remove_duplicate_points.rb
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RemoveDuplicatePoints < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
# Find duplicate groups using a subquery
|
||||
duplicate_groups =
|
||||
Point.select('latitude, longitude, timestamp, user_id, COUNT(*) as count')
|
||||
.group('latitude, longitude, timestamp, user_id')
|
||||
.having('COUNT(*) > 1')
|
||||
|
||||
puts "Duplicate groups found: #{duplicate_groups.length}"
|
||||
|
||||
duplicate_groups.each do |group|
|
||||
points = Point.where(
|
||||
latitude: group.latitude,
|
||||
longitude: group.longitude,
|
||||
timestamp: group.timestamp,
|
||||
user_id: group.user_id
|
||||
).order(id: :asc)
|
||||
|
||||
# Keep the latest record and destroy all others
|
||||
latest = points.last
|
||||
points.where.not(id: latest.id).destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
# This migration cannot be reversed
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
|
|
@ -1 +1 @@
|
|||
DataMigrate::Data.define(version: 20250104204852)
|
||||
DataMigrate::Data.define(version: 20250120154554)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddCourseAndCourseAccuracyToPoints < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :points, :course, :decimal, precision: 8, scale: 5
|
||||
add_column :points, :course_accuracy, :decimal, precision: 8, scale: 5
|
||||
end
|
||||
end
|
||||
11
db/migrate/20250120152540_add_external_track_id_to_points.rb
Normal file
11
db/migrate/20250120152540_add_external_track_id_to_points.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddExternalTrackIdToPoints < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_column :points, :external_track_id, :string
|
||||
|
||||
add_index :points, :external_track_id, algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
16
db/migrate/20250120154555_add_unique_index_to_points.rb
Normal file
16
db/migrate/20250120154555_add_unique_index_to_points.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddUniqueIndexToPoints < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_index :points, %i[latitude longitude timestamp user_id],
|
||||
unique: true,
|
||||
name: 'unique_points_index',
|
||||
algorithm: :concurrently
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :points, name: 'unique_points_index'
|
||||
end
|
||||
end
|
||||
7
db/schema.rb
generated
7
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2024_12_11_113119) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_01_20_154555) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
||||
|
|
@ -156,14 +156,19 @@ ActiveRecord::Schema[8.0].define(version: 2024_12_11_113119) do
|
|||
t.jsonb "geodata", default: {}, null: false
|
||||
t.bigint "visit_id"
|
||||
t.datetime "reverse_geocoded_at"
|
||||
t.decimal "course", precision: 8, scale: 5
|
||||
t.decimal "course_accuracy", precision: 8, scale: 5
|
||||
t.string "external_track_id"
|
||||
t.index ["altitude"], name: "index_points_on_altitude"
|
||||
t.index ["battery"], name: "index_points_on_battery"
|
||||
t.index ["battery_status"], name: "index_points_on_battery_status"
|
||||
t.index ["city"], name: "index_points_on_city"
|
||||
t.index ["connection"], name: "index_points_on_connection"
|
||||
t.index ["country"], name: "index_points_on_country"
|
||||
t.index ["external_track_id"], name: "index_points_on_external_track_id"
|
||||
t.index ["geodata"], name: "index_points_on_geodata", using: :gin
|
||||
t.index ["import_id"], name: "index_points_on_import_id"
|
||||
t.index ["latitude", "longitude", "timestamp", "user_id"], name: "unique_points_index", unique: true
|
||||
t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude"
|
||||
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
|
||||
t.index ["timestamp"], name: "index_points_on_timestamp"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ FactoryBot.define do
|
|||
import_id { '' }
|
||||
city { nil }
|
||||
country { nil }
|
||||
reverse_geocoded_at { nil }
|
||||
course { nil }
|
||||
course_accuracy { nil }
|
||||
external_track_id { nil }
|
||||
user
|
||||
|
||||
trait :with_known_location do
|
||||
|
|
|
|||
|
|
@ -10,12 +10,16 @@ FactoryBot.define do
|
|||
|
||||
trait :with_points do
|
||||
after(:build) do |trip|
|
||||
create_list(
|
||||
:point, 25,
|
||||
user: trip.user,
|
||||
timestamp: trip.started_at + (1..1000).to_a.sample.minutes
|
||||
(1..25).map do |i|
|
||||
create(
|
||||
:point,
|
||||
:with_geodata,
|
||||
:reverse_geocoded,
|
||||
timestamp: trip.started_at + i.minutes,
|
||||
user: trip.user
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
136
spec/fixtures/files/points/geojson_example.json
vendored
Normal file
136
spec/fixtures/files/points/geojson_example.json
vendored
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
{
|
||||
"locations" : [
|
||||
{
|
||||
"type" : "Feature",
|
||||
"geometry" : {
|
||||
"type" : "Point",
|
||||
"coordinates" : [
|
||||
-122.40530871,
|
||||
37.744304130000003
|
||||
]
|
||||
},
|
||||
"properties" : {
|
||||
"horizontal_accuracy" : 5,
|
||||
"track_id" : "799F32F5-89BB-45FB-A639-098B1B95B09F",
|
||||
"speed_accuracy" : 0,
|
||||
"vertical_accuracy" : -1,
|
||||
"course_accuracy" : 0,
|
||||
"altitude" : 0,
|
||||
"speed" : 92.087999999999994,
|
||||
"course" : 27.07,
|
||||
"timestamp" : "2025-01-17T21:03:01Z",
|
||||
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type" : "Feature",
|
||||
"properties" : {
|
||||
"timestamp" : "2025-01-17T21:03:02Z",
|
||||
"horizontal_accuracy" : 5,
|
||||
"course" : 24.260000000000002,
|
||||
"speed_accuracy" : 0,
|
||||
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46",
|
||||
"vertical_accuracy" : -1,
|
||||
"altitude" : 0,
|
||||
"track_id" : "799F32F5-89BB-45FB-A639-098B1B95B09F",
|
||||
"speed" : 92.448000000000008,
|
||||
"course_accuracy" : 0
|
||||
},
|
||||
"geometry" : {
|
||||
"type" : "Point",
|
||||
"coordinates" : [
|
||||
-122.40518926999999,
|
||||
37.744513759999997
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type" : "Feature",
|
||||
"properties" : {
|
||||
"altitude" : 0,
|
||||
"horizontal_accuracy" : 5,
|
||||
"speed" : 123.76800000000001,
|
||||
"course_accuracy" : 0,
|
||||
"speed_accuracy" : 0,
|
||||
"course" : 309.73000000000002,
|
||||
"track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888",
|
||||
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46",
|
||||
"timestamp" : "2025-01-17T21:18:38Z",
|
||||
"vertical_accuracy" : -1
|
||||
},
|
||||
"geometry" : {
|
||||
"type" : "Point",
|
||||
"coordinates" : [
|
||||
-122.28487643,
|
||||
37.454486080000002
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type" : "Feature",
|
||||
"properties" : {
|
||||
"track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888",
|
||||
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46",
|
||||
"speed_accuracy" : 0,
|
||||
"course_accuracy" : 0,
|
||||
"speed" : 123.3,
|
||||
"horizontal_accuracy" : 5,
|
||||
"course" : 309.38,
|
||||
"altitude" : 0,
|
||||
"timestamp" : "2025-01-17T21:18:39Z",
|
||||
"vertical_accuracy" : -1
|
||||
},
|
||||
"geometry" : {
|
||||
"coordinates" : [
|
||||
-122.28517332,
|
||||
37.454684899999997
|
||||
],
|
||||
"type" : "Point"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry" : {
|
||||
"coordinates" : [
|
||||
-122.28547306,
|
||||
37.454883219999999
|
||||
],
|
||||
"type" : "Point"
|
||||
},
|
||||
"properties" : {
|
||||
"course_accuracy" : 0,
|
||||
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46",
|
||||
"vertical_accuracy" : -1,
|
||||
"course" : 309.73000000000002,
|
||||
"speed_accuracy" : 0,
|
||||
"timestamp" : "2025-01-17T21:18:40Z",
|
||||
"horizontal_accuracy" : 5,
|
||||
"speed" : 125.06400000000001,
|
||||
"track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888",
|
||||
"altitude" : 0
|
||||
},
|
||||
"type" : "Feature"
|
||||
},
|
||||
{
|
||||
"geometry" : {
|
||||
"type" : "Point",
|
||||
"coordinates" : [
|
||||
-122.28577665,
|
||||
37.455080109999997
|
||||
]
|
||||
},
|
||||
"properties" : {
|
||||
"course_accuracy" : 0,
|
||||
"speed_accuracy" : 0,
|
||||
"speed" : 124.05600000000001,
|
||||
"track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888",
|
||||
"course" : 309.73000000000002,
|
||||
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46",
|
||||
"altitude" : 0,
|
||||
"horizontal_accuracy" : 5,
|
||||
"vertical_accuracy" : -1,
|
||||
"timestamp" : "2025-01-17T21:18:41Z"
|
||||
},
|
||||
"type" : "Feature"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -9,8 +9,17 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do
|
|||
|
||||
let(:timestamp) { DateTime.new(2024, 1, 1).to_i }
|
||||
|
||||
let!(:points1) { create_list(:point, 10, user_id: user1.id, timestamp:) }
|
||||
let!(:points2) { create_list(:point, 10, user_id: user2.id, timestamp:) }
|
||||
let!(:points1) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user_id: user1.id, timestamp: timestamp + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:points2) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user_id: user2.id, timestamp: timestamp + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
it 'enqueues Stats::CalculatingJob for each user' do
|
||||
expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1)
|
||||
|
|
|
|||
18
spec/jobs/points/create_job_spec.rb
Normal file
18
spec/jobs/points/create_job_spec.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Points::CreateJob, type: :job do
|
||||
describe '#perform' do
|
||||
subject(:perform) { described_class.new.perform(json, user.id) }
|
||||
|
||||
let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' }
|
||||
let(:file) { File.open(file_path) }
|
||||
let(:json) { JSON.parse(file.read) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it 'creates a point' do
|
||||
expect { perform }.to change { Point.count }.by(6)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -26,7 +26,11 @@ RSpec.describe Import, type: :model do
|
|||
describe '#years_and_months_tracked' do
|
||||
let(:import) { create(:import) }
|
||||
let(:timestamp) { Time.zone.local(2024, 11, 1) }
|
||||
let!(:points) { create_list(:point, 3, import:, timestamp:) }
|
||||
let!(:points) do
|
||||
(1..3).map do |i|
|
||||
create(:point, import:, timestamp: timestamp + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns years and months tracked' do
|
||||
expect(import.years_and_months_tracked).to eq([[2024, 11]])
|
||||
|
|
|
|||
|
|
@ -89,8 +89,14 @@ RSpec.describe Stat, type: :model do
|
|||
subject { stat.points.to_a }
|
||||
|
||||
let(:stat) { create(:stat, year:, month: 1, user:) }
|
||||
let(:timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) }
|
||||
let!(:points) { create_list(:point, 3, user:, timestamp:) }
|
||||
let(:base_timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) }
|
||||
let!(:points) do
|
||||
[
|
||||
create(:point, user:, timestamp: base_timestamp),
|
||||
create(:point, user:, timestamp: base_timestamp + 1.hour),
|
||||
create(:point, user:, timestamp: base_timestamp + 2.hours)
|
||||
]
|
||||
end
|
||||
|
||||
it 'returns points' do
|
||||
expect(subject).to eq(points)
|
||||
|
|
|
|||
|
|
@ -115,7 +115,11 @@ RSpec.describe User, type: :model do
|
|||
end
|
||||
|
||||
describe '#years_tracked' do
|
||||
let!(:points) { create_list(:point, 3, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0)) }
|
||||
let!(:points) do
|
||||
(1..3).map do |i|
|
||||
create(:point, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0) + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns years tracked' do
|
||||
expect(user.years_tracked).to eq([{ year: 2024, months: ['Jan'] }])
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe 'Api::V1::Points', type: :request do
|
||||
let!(:user) { create(:user) }
|
||||
let!(:points) { create_list(:point, 150, user:) }
|
||||
let!(:points) do
|
||||
(1..15).map do |i|
|
||||
create(:point, user:, timestamp: 1.day.ago + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /index' do
|
||||
context 'when regular version of points is requested' do
|
||||
|
|
@ -21,7 +25,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
|
|||
|
||||
json_response = JSON.parse(response.body)
|
||||
|
||||
expect(json_response.size).to eq(100)
|
||||
expect(json_response.size).to eq(15)
|
||||
end
|
||||
|
||||
it 'returns a list of points with pagination' do
|
||||
|
|
@ -31,7 +35,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
|
|||
|
||||
json_response = JSON.parse(response.body)
|
||||
|
||||
expect(json_response.size).to eq(10)
|
||||
expect(json_response.size).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns a list of points with pagination headers' do
|
||||
|
|
@ -40,7 +44,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
|
|||
expect(response).to have_http_status(:ok)
|
||||
|
||||
expect(response.headers['X-Current-Page']).to eq('2')
|
||||
expect(response.headers['X-Total-Pages']).to eq('15')
|
||||
expect(response.headers['X-Total-Pages']).to eq('2')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -58,7 +62,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
|
|||
|
||||
json_response = JSON.parse(response.body)
|
||||
|
||||
expect(json_response.size).to eq(100)
|
||||
expect(json_response.size).to eq(15)
|
||||
end
|
||||
|
||||
it 'returns a list of points with pagination' do
|
||||
|
|
@ -68,7 +72,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
|
|||
|
||||
json_response = JSON.parse(response.body)
|
||||
|
||||
expect(json_response.size).to eq(10)
|
||||
expect(json_response.size).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns a list of points with pagination headers' do
|
||||
|
|
@ -77,7 +81,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
|
|||
expect(response).to have_http_status(:ok)
|
||||
|
||||
expect(response.headers['X-Current-Page']).to eq('2')
|
||||
expect(response.headers['X-Total-Pages']).to eq('15')
|
||||
expect(response.headers['X-Total-Pages']).to eq('2')
|
||||
end
|
||||
|
||||
it 'returns a list of points with slim attributes' do
|
||||
|
|
|
|||
|
|
@ -10,14 +10,20 @@ RSpec.describe 'Api::V1::Stats', type: :request do
|
|||
let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) }
|
||||
let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) }
|
||||
let!(:points_in_2020) do
|
||||
create_list(:point, 85, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020), user:)
|
||||
(1..85).map do |i|
|
||||
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, user:)
|
||||
end
|
||||
end
|
||||
let!(:points_in_2021) do
|
||||
(1..95).map do |i|
|
||||
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, user:)
|
||||
end
|
||||
end
|
||||
let!(:points_in_2021) { create_list(:point, 95, timestamp: Time.zone.local(2021), user:) }
|
||||
let(:expected_json) do
|
||||
{
|
||||
totalDistanceKm: stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum,
|
||||
totalPointsTracked: points_in_2020.count + points_in_2021.count,
|
||||
totalReverseGeocodedPoints: points_in_2020.count,
|
||||
totalReverseGeocodedPoints: points_in_2020.count + points_in_2021.count,
|
||||
totalCountriesVisited: 1,
|
||||
totalCitiesVisited: 1,
|
||||
yearlyStats: [
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@ RSpec.describe '/exports', type: :request do
|
|||
before { sign_in user }
|
||||
|
||||
context 'with valid parameters' do
|
||||
let(:points) { create_list(:point, 10, user:, timestamp: 1.day.ago) }
|
||||
let(:points) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user:, timestamp: 1.day.ago + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a new Export' do
|
||||
expect { post exports_url, params: }.to change(Export, :count).by(1)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ RSpec.describe 'Map', type: :request do
|
|||
describe 'GET /index' do
|
||||
context 'when user signed in' do
|
||||
let(:user) { create(:user) }
|
||||
let(:points) { create_list(:point, 10, user:, timestamp: 1.day.ago) }
|
||||
let(:points) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user:, timestamp: 1.day.ago + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
before { sign_in user }
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ RSpec.describe ExportSerializer do
|
|||
subject(:serializer) { described_class.new(points, user_email).call }
|
||||
|
||||
let(:user_email) { 'ab@cd.com' }
|
||||
let(:points) { create_list(:point, 2) }
|
||||
let(:points) do
|
||||
(1..2).map do |i|
|
||||
create(:point, timestamp: 1.day.ago + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
let(:expected_json) do
|
||||
{
|
||||
user_email => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ RSpec.describe Points::GeojsonSerializer do
|
|||
describe '#call' do
|
||||
subject(:serializer) { described_class.new(points).call }
|
||||
|
||||
let(:points) { create_list(:point, 3) }
|
||||
let(:points) do
|
||||
(1..3).map do |i|
|
||||
create(:point, timestamp: 1.day.ago + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
let(:expected_json) do
|
||||
{
|
||||
type: 'FeatureCollection',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ RSpec.describe Points::GpxSerializer do
|
|||
describe '#call' do
|
||||
subject(:serializer) { described_class.new(points, 'some_name').call }
|
||||
|
||||
let(:points) { create_list(:point, 3) }
|
||||
let(:points) do
|
||||
(1..3).map do |i|
|
||||
create(:point, timestamp: 1.day.ago + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns GPX file' do
|
||||
expect(serializer).to be_a(GPX::GPXFile)
|
||||
|
|
|
|||
|
|
@ -29,16 +29,20 @@ RSpec.describe StatsSerializer do
|
|||
let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) }
|
||||
let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) }
|
||||
let!(:points_in_2020) do
|
||||
create_list(:point, 85, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020), user:)
|
||||
(1..85).map do |i|
|
||||
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, user:)
|
||||
end
|
||||
end
|
||||
let!(:points_in_2021) do
|
||||
create_list(:point, 95, timestamp: Time.zone.local(2021), user:)
|
||||
(1..95).map do |i|
|
||||
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, user:)
|
||||
end
|
||||
end
|
||||
let(:expected_json) do
|
||||
{
|
||||
"totalDistanceKm": stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum,
|
||||
"totalPointsTracked": points_in_2020.count + points_in_2021.count,
|
||||
"totalReverseGeocodedPoints": points_in_2020.count,
|
||||
"totalReverseGeocodedPoints": points_in_2020.count + points_in_2021.count,
|
||||
"totalCountriesVisited": 1,
|
||||
"totalCitiesVisited": 1,
|
||||
"yearlyStats": [
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@ RSpec.describe Exports::Create do
|
|||
let(:export_content) { Points::GeojsonSerializer.new(points).call }
|
||||
let(:reverse_geocoded_at) { Time.zone.local(2021, 1, 1) }
|
||||
let!(:points) do
|
||||
create_list(:point, 10, :with_known_location, user:, timestamp: start_at.to_datetime.to_i, reverse_geocoded_at:)
|
||||
10.times.map do |i|
|
||||
create(:point, :with_known_location,
|
||||
user: user,
|
||||
timestamp: start_at.to_datetime.to_i + i,
|
||||
reverse_geocoded_at: reverse_geocoded_at)
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ RSpec.describe GoogleMaps::RecordsParser do
|
|||
subject(:parser) { described_class.new(import).call(json) }
|
||||
|
||||
let(:import) { create(:import) }
|
||||
let(:time) { Time.zone.now }
|
||||
let(:time) { DateTime.new(2025, 1, 1, 12, 0, 0) }
|
||||
let(:json) do
|
||||
{
|
||||
'latitudeE7' => 123_456_789,
|
||||
|
|
@ -31,7 +31,7 @@ RSpec.describe GoogleMaps::RecordsParser do
|
|||
before do
|
||||
create(
|
||||
:point, user: import.user, import:, latitude: 12.3456789, longitude: 12.3456789,
|
||||
timestamp: Time.zone.now.to_i
|
||||
timestamp: time.to_i
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ RSpec.describe Jobs::Create do
|
|||
|
||||
context 'when job_name is start_reverse_geocoding' do
|
||||
let(:user) { create(:user) }
|
||||
let(:points) { create_list(:point, 4, user:) }
|
||||
let(:points) do
|
||||
(1..4).map do |i|
|
||||
create(:point, user:, timestamp: 1.day.ago + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
let(:job_name) { 'start_reverse_geocoding' }
|
||||
|
||||
it 'enqueues reverse geocoding for all user points' do
|
||||
|
|
@ -24,8 +29,17 @@ RSpec.describe Jobs::Create do
|
|||
|
||||
context 'when job_name is continue_reverse_geocoding' do
|
||||
let(:user) { create(:user) }
|
||||
let(:points_without_address) { create_list(:point, 4, user:, country: nil, city: nil) }
|
||||
let(:points_with_address) { create_list(:point, 5, user:, country: 'Country', city: 'City') }
|
||||
let(:points_without_address) do
|
||||
(1..4).map do |i|
|
||||
create(:point, user:, country: nil, city: nil, timestamp: 1.day.ago + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
let(:points_with_address) do
|
||||
(1..5).map do |i|
|
||||
create(:point, user:, country: 'Country', city: 'City', timestamp: 1.day.ago + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
let(:job_name) { 'continue_reverse_geocoding' }
|
||||
|
||||
|
|
|
|||
68
spec/services/points/params_spec.rb
Normal file
68
spec/services/points/params_spec.rb
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Points::Params do
|
||||
describe '#call' do
|
||||
let(:user) { create(:user) }
|
||||
let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' }
|
||||
let(:file) { File.open(file_path) }
|
||||
let(:json) { JSON.parse(file.read) }
|
||||
let(:expected_json) do
|
||||
{
|
||||
latitude: 37.74430413,
|
||||
longitude: -122.40530871,
|
||||
battery_status: nil,
|
||||
battery: nil,
|
||||
timestamp: DateTime.parse('2025-01-17T21:03:01Z'),
|
||||
altitude: 0,
|
||||
tracker_id: '8D5D4197-245B-4619-A88B-2049100ADE46',
|
||||
velocity: 92.088,
|
||||
ssid: nil,
|
||||
accuracy: 5,
|
||||
vertical_accuracy: -1,
|
||||
course_accuracy: 0,
|
||||
course: 27.07,
|
||||
raw_data: {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [-122.40530871, 37.74430413]
|
||||
},
|
||||
properties: {
|
||||
horizontal_accuracy: 5,
|
||||
track_id: '799F32F5-89BB-45FB-A639-098B1B95B09F',
|
||||
speed_accuracy: 0,
|
||||
vertical_accuracy: -1,
|
||||
course_accuracy: 0,
|
||||
altitude: 0,
|
||||
speed: 92.088,
|
||||
course: 27.07,
|
||||
timestamp: '2025-01-17T21:03:01Z',
|
||||
device_id: '8D5D4197-245B-4619-A88B-2049100ADE46'
|
||||
}
|
||||
}.with_indifferent_access,
|
||||
user_id: user.id
|
||||
}
|
||||
end
|
||||
|
||||
subject(:params) { described_class.new(json, user.id).call }
|
||||
|
||||
it 'returns an array of points' do
|
||||
expect(params).to be_an(Array)
|
||||
expect(params.first).to eq(expected_json)
|
||||
end
|
||||
|
||||
it 'returns the correct number of points' do
|
||||
expect(params.size).to eq(6)
|
||||
end
|
||||
|
||||
it 'returns correct keys' do
|
||||
expect(params.first.keys).to eq(expected_json.keys)
|
||||
end
|
||||
|
||||
it 'returns the correct values' do
|
||||
expect(params.first).to eq(expected_json)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -58,7 +58,92 @@ describe 'Points API', type: :request do
|
|||
let(:api_key) { user.api_key }
|
||||
let(:start_at) { Time.zone.now - 1.day }
|
||||
let(:end_at) { Time.zone.now }
|
||||
let(:points) { create_list(:point, 10, user:, timestamp: 2.hours.ago) }
|
||||
let(:points) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user:, timestamp: 2.hours.ago + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
post 'Creates a batch of points' do
|
||||
request_body_example value: {
|
||||
locations: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [-122.40530871, 37.74430413]
|
||||
},
|
||||
properties: {
|
||||
timestamp: '2025-01-17T21:03:01Z',
|
||||
horizontal_accuracy: 5,
|
||||
vertical_accuracy: -1,
|
||||
altitude: 0,
|
||||
speed: 92.088,
|
||||
speed_accuracy: 0,
|
||||
course: 27.07,
|
||||
course_accuracy: 0,
|
||||
track_id: '799F32F5-89BB-45FB-A639-098B1B95B09F',
|
||||
device_id: '8D5D4197-245B-4619-A88B-2049100ADE46'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
tags 'Batches'
|
||||
consumes 'application/json'
|
||||
parameter name: :locations, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
type: { type: :string },
|
||||
geometry: {
|
||||
type: :object,
|
||||
properties: {
|
||||
type: { type: :string },
|
||||
coordinates: { type: :array, items: { type: :number } }
|
||||
}
|
||||
},
|
||||
properties: {
|
||||
type: :object,
|
||||
properties: {
|
||||
timestamp: { type: :string },
|
||||
horizontal_accuracy: { type: :number },
|
||||
vertical_accuracy: { type: :number },
|
||||
altitude: { type: :number },
|
||||
speed: { type: :number },
|
||||
speed_accuracy: { type: :number },
|
||||
course: { type: :number },
|
||||
course_accuracy: { type: :number },
|
||||
track_id: { type: :string },
|
||||
device_id: { type: :string }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: %w[geometry properties]
|
||||
}
|
||||
|
||||
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
|
||||
|
||||
response '200', 'Batch of points being processed' do
|
||||
let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' }
|
||||
let(:file) { File.open(file_path) }
|
||||
let(:json) { JSON.parse(file.read) }
|
||||
let(:params) { json }
|
||||
let(:locations) { params['locations'] }
|
||||
let(:api_key) { create(:user).api_key }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'Unauthorized' do
|
||||
let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' }
|
||||
let(:file) { File.open(file_path) }
|
||||
let(:json) { JSON.parse(file.read) }
|
||||
let(:params) { json }
|
||||
let(:locations) { params['locations'] }
|
||||
let(:api_key) { 'invalid_api_key' }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
|
|
|||
|
|
@ -57,8 +57,18 @@ describe 'Stats API', type: :request do
|
|||
let!(:user) { create(:user) }
|
||||
let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) }
|
||||
let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) }
|
||||
let!(:points_in_2020) { create_list(:point, 85, :with_geodata, timestamp: Time.zone.local(2020), user:) }
|
||||
let!(:points_in_2021) { create_list(:point, 95, timestamp: Time.zone.local(2021), user:) }
|
||||
let!(:points_in_2020) do
|
||||
(1..85).map do |i|
|
||||
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours,
|
||||
user:)
|
||||
end
|
||||
end
|
||||
let!(:points_in_2021) do
|
||||
(1..95).map do |i|
|
||||
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours,
|
||||
user:)
|
||||
end
|
||||
end
|
||||
let(:api_key) { user.api_key }
|
||||
|
||||
run_test!
|
||||
|
|
|
|||
|
|
@ -696,6 +696,87 @@ paths:
|
|||
type: string
|
||||
visit_id:
|
||||
type: string
|
||||
post:
|
||||
summary: Creates a batch of points
|
||||
tags:
|
||||
- Batches
|
||||
parameters:
|
||||
- name: api_key
|
||||
in: query
|
||||
required: true
|
||||
description: API Key
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Batch of points being processed
|
||||
'401':
|
||||
description: Unauthorized
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
geometry:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
coordinates:
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
properties:
|
||||
type: object
|
||||
properties:
|
||||
timestamp:
|
||||
type: string
|
||||
horizontal_accuracy:
|
||||
type: number
|
||||
vertical_accuracy:
|
||||
type: number
|
||||
altitude:
|
||||
type: number
|
||||
speed:
|
||||
type: number
|
||||
speed_accuracy:
|
||||
type: number
|
||||
course:
|
||||
type: number
|
||||
course_accuracy:
|
||||
type: number
|
||||
track_id:
|
||||
type: string
|
||||
device_id:
|
||||
type: string
|
||||
required:
|
||||
- geometry
|
||||
- properties
|
||||
examples:
|
||||
'0':
|
||||
summary: Creates a batch of points
|
||||
value:
|
||||
locations:
|
||||
- type: Feature
|
||||
geometry:
|
||||
type: Point
|
||||
coordinates:
|
||||
- -122.40530871
|
||||
- 37.74430413
|
||||
properties:
|
||||
timestamp: '2025-01-17T21:03:01Z'
|
||||
horizontal_accuracy: 5
|
||||
vertical_accuracy: -1
|
||||
altitude: 0
|
||||
speed: 92.088
|
||||
speed_accuracy: 0
|
||||
course: 27.07
|
||||
course_accuracy: 0
|
||||
track_id: 799F32F5-89BB-45FB-A639-098B1B95B09F
|
||||
device_id: 8D5D4197-245B-4619-A88B-2049100ADE46
|
||||
"/api/v1/points/{id}":
|
||||
delete:
|
||||
summary: Deletes a point
|
||||
|
|
|
|||
Loading…
Reference in a new issue