diff --git a/app/controllers/application_api_controller.rb b/app/controllers/application_api_controller.rb new file mode 100644 index 00000000..56cf7c43 --- /dev/null +++ b/app/controllers/application_api_controller.rb @@ -0,0 +1,72 @@ +class ApplicationAPIController < ActionController::API + + include ActionController::ImplicitRender + include ActionController::Helpers + include ActionController::HttpAuthentication::Basic::ControllerMethods + include ActionController::HttpAuthentication::Token::ControllerMethods + include ActionController::Caching + + include Pundit::Authorization + include PrettyJSON + include ErrorHandlers + + include SharedControllerMethods + + include ::UserHelper + helper ::UserHelper + + include ::PresentationHelper + helper ::PresentationHelper + + before_action :set_rate_limit_whitelist + after_action :verify_authorized, except: :index + respond_to :json + + private + + def set_rate_limit_whitelist + if current_user(false)&.is_admin_or_researcher? + Rack::Attack.cache.write("throttle_whitelist_#{request.remote_ip}", true, 5.minutes) + end + end + + def check_if_authorized! + if current_user.nil? + if params[:access_token] + raise Smartcitizen::Unauthorized.new("Invalid OAuth2 Params") + else + raise Smartcitizen::Unauthorized.new("Authorization required") + end + end + end + + def raise_ransack_errors_as_bad_request(&block) + begin + block.call + rescue ArgumentError => e + render json: { message: e.message, status: 400 }, status: 400 + end + end + + def check_missing_params *params_list + missing_params = [] + params_list.each do |param| + individual_params = param.split("||") + missing_params << individual_params.join(" OR ") unless (params.keys & individual_params).any? + end + raise ActionController::ParameterMissing.new(missing_params.to_sentence) if missing_params.any? + end + + def check_date_param_format(param_name) + return true if !params[param_name] + return true if params[param_name] =~ /^\d+$/ + begin + Time.parse(params[param_name]) + return true + rescue + message = "The #{param_name} parameter must be an ISO8601 format date or datetime or an integer number of seconds since the start of the UNIX epoch." + render json: { message: message, status: 400 }, status: 400 + return false + end + end +end diff --git a/app/controllers/v0/application_controller.rb b/app/controllers/v0/application_controller.rb index 8be8590b..16f505a4 100644 --- a/app/controllers/v0/application_controller.rb +++ b/app/controllers/v0/application_controller.rb @@ -1,80 +1,12 @@ -require_relative '../../helpers/user_helper' module V0 - class ApplicationController < ActionController::API - - include ActionController::HttpAuthentication::Basic::ControllerMethods - include ActionController::HttpAuthentication::Token::ControllerMethods - include ActionController::Helpers - include ActionController::ImplicitRender - include ActionController::Caching - - include PrettyJSON - include ErrorHandlers - - helper ::UserHelper - include ::UserHelper - - include SharedControllerMethods - - respond_to :json - + class ApplicationController < ::ApplicationAPIController before_action :prepend_view_paths - before_action :set_rate_limit_whitelist - after_action :verify_authorized, except: :index - - protected - - def check_missing_params *params_list - missing_params = [] - params_list.each do |param| - individual_params = param.split("||") - missing_params << individual_params.join(" OR ") unless (params.keys & individual_params).any? - end - raise ActionController::ParameterMissing.new(missing_params.to_sentence) if missing_params.any? - end - - def check_date_param_format(param_name) - return true if !params[param_name] - return true if params[param_name] =~ /^\d+$/ - begin - Time.parse(params[param_name]) - return true - rescue - message = "The #{param_name} parameter must be an ISO8601 format date or datetime or an integer number of seconds since the start of the UNIX epoch." - render json: { message: message, status: 400 }, status: 400 - return false - end - end private - def raise_ransack_errors_as_bad_request(&block) - begin - block.call - rescue ArgumentError => e - render json: { message: e.message, status: 400 }, status: 400 - end - end - def prepend_view_paths # is this still necessary? prepend_view_path "app/views/v0" end - - def set_rate_limit_whitelist - if current_user(false)&.is_admin_or_researcher? - Rack::Attack.cache.write("throttle_whitelist_#{request.remote_ip}", true, 5.minutes) - end - end - - def check_if_authorized! - if current_user.nil? - if params[:access_token] - raise Smartcitizen::Unauthorized.new("Invalid OAuth2 Params") - else - raise Smartcitizen::Unauthorized.new("Authorization required") - end - end - end end end diff --git a/app/controllers/v1/application_controller.rb b/app/controllers/v1/application_controller.rb new file mode 100644 index 00000000..579dbfe2 --- /dev/null +++ b/app/controllers/v1/application_controller.rb @@ -0,0 +1,4 @@ +module V1 + class ApplicationController < ::ApplicationAPIController + end +end diff --git a/app/controllers/v1/devices_controller.rb b/app/controllers/v1/devices_controller.rb new file mode 100644 index 00000000..0377fe7f --- /dev/null +++ b/app/controllers/v1/devices_controller.rb @@ -0,0 +1,35 @@ +module V1 + class DevicesController < ApplicationController + def show + @device = Device.includes( + :owner,:tags, {sensors: :measurement}).find(params[:id]) + authorize @device + render json: present(@device) + end + + #TODO Document breaking API change as detailed in https://github.com/fablabbcn/smartcitizen-api/issues/186 + def index + raise_ransack_errors_as_bad_request do + @q = policy_scope(Device) + .includes(:owner, :tags, :components, {sensors: :measurement}) + .ransack(params[:q], auth_object: (current_user&.is_admin? ? :admin : nil)) + + @devices = @q.result(distinct: true) + + if params[:near] + if params[:near] =~ /\A(\-?\d+(\.\d+)?),\s*(\-?\d+(\.\d+)?)\z/ + @devices = @devices.near( + params[:near].split(','), (params[:within] || 1000)) + else + return render json: { id: "bad_request", + message: "Malformed near parameter", + url: 'https://developer.smartcitizen.me/#get-all-devices', + errors: nil }, status: :bad_request + end + end + @devices = paginate(@devices) + render json: present(@devices) + end + end + end +end diff --git a/app/helpers/presentation_helper.rb b/app/helpers/presentation_helper.rb new file mode 100644 index 00000000..3a0c8bac --- /dev/null +++ b/app/helpers/presentation_helper.rb @@ -0,0 +1,5 @@ +module PresentationHelper + def present(model, options={}) + Presenters.present(model, current_user, self, options) + end +end diff --git a/app/jobs/mqtt_forwarding_job.rb b/app/jobs/mqtt_forwarding_job.rb index 8707bc34..7798001a 100644 --- a/app/jobs/mqtt_forwarding_job.rb +++ b/app/jobs/mqtt_forwarding_job.rb @@ -2,12 +2,13 @@ class MQTTForwardingJob < ApplicationJob queue_as :mqtt_forward - def perform(device_id, reading) + def perform(device_id, data) + readings = data[:readings] + device = Device.find(device_id) begin - device = Device.find(device_id) forwarder = MQTTForwarder.new(mqtt_client) - payload = payload_for(device, reading) - forwarder.forward_reading(device.forwarding_token, device.id, payload) + payload = payload_for(device, readings) + forwarder.forward_readings(device.forwarding_token, device.id, payload) ensure disconnect_mqtt! end @@ -15,15 +16,8 @@ def perform(device_id, reading) private - def payload_for(device, reading) - renderer.render( - partial: "v0/devices/device", - locals: { - device: device.reload, - current_user: nil, - slim_owner: true - } - ) + def payload_for(device, readings) + Presenters.present(device, device.owner, nil, readings: readings).to_json end def mqtt_client @@ -32,10 +26,6 @@ def mqtt_client }) end - def renderer - @renderer ||= ActionController::Base.new.view_context - end - def disconnect_mqtt! @mqtt_client&.disconnect end diff --git a/app/jobs/send_to_datastore_job.rb b/app/jobs/send_to_datastore_job.rb index 29cb3d7c..46816ffd 100644 --- a/app/jobs/send_to_datastore_job.rb +++ b/app/jobs/send_to_datastore_job.rb @@ -3,12 +3,8 @@ class SendToDatastoreJob < ApplicationJob def perform(data_param, device_id) @device = Device.includes(:components).find(device_id) - the_data = JSON.parse(data_param) - the_data.sort_by {|a| a['recorded_at']}.reverse.each_with_index do |reading, index| - # move to async method call - do_update = index == 0 - storer.store(@device, reading, do_update) - end + readings = JSON.parse(data_param) + storer.store(@device, readings) end def storer diff --git a/app/lib/mqtt_forwarder.rb b/app/lib/mqtt_forwarder.rb index 9a37d1c0..4522d575 100644 --- a/app/lib/mqtt_forwarder.rb +++ b/app/lib/mqtt_forwarder.rb @@ -5,7 +5,7 @@ def initialize(client) @suffix = suffix end - def forward_reading(token, device_id, reading) + def forward_readings(token, device_id, reading) topic = topic_path(token, device_id) client.publish(topic, reading) end diff --git a/app/lib/mqtt_messages_handler.rb b/app/lib/mqtt_messages_handler.rb index 62282114..0fd8156b 100644 --- a/app/lib/mqtt_messages_handler.rb +++ b/app/lib/mqtt_messages_handler.rb @@ -37,9 +37,7 @@ def handle_readings(device, message) parsed = JSON.parse(message) if message data = parsed["data"] if parsed return nil if data.nil? or data&.empty? - data.each do |reading| - storer.store(device, reading) - end + storer.store(device, data) return true rescue Exception => e Sentry.capture_exception(e) diff --git a/app/lib/presenters.rb b/app/lib/presenters.rb new file mode 100644 index 00000000..ba997c8d --- /dev/null +++ b/app/lib/presenters.rb @@ -0,0 +1,24 @@ +module Presenters + # This is work in progress we're releasing early so + # that it can be used in forwarding to send the current + # values as they're received. + # TODO: add presenter tests + # use in appropriate views, delete unneeded code in models and views. + PRESENTERS = { + Device => Presenters::DevicePresenter, + User => Presenters::UserPresenter, + Component => Presenters::ComponentPresenter, + Sensor => Presenters::SensorPresenter, + Measurement => Presenters::MeasurementPresenter, + } + + def self.present(model_or_collection, user, render_context, options={}) + if model_or_collection.is_a?(Enumerable) + model_or_collection.map { |model| present(model, user, render_context, options) } + else + PRESENTERS[model_or_collection.class]&.new( + model_or_collection, user, render_context, options + ).as_json + end + end +end diff --git a/app/lib/presenters/base_presenter.rb b/app/lib/presenters/base_presenter.rb new file mode 100644 index 00000000..cd6cbb2a --- /dev/null +++ b/app/lib/presenters/base_presenter.rb @@ -0,0 +1,64 @@ +module Presenters + class BasePresenter + + def default_options + {} + end + + def exposed_fields + [] + end + + def initialize(model, current_user=nil, render_context=nil, options={}) + @model = model + @current_user = current_user + @render_context = render_context + @unauthorized_fields = [] + @options = self.default_options.merge(options) + end + + def as_json(_opts=nil) + values = self.exposed_fields.inject({}) { |hash, field| + value = self.send(field) + value.nil? ? hash : hash.merge(field => value) + } + unauthorized_fields.each do |field_path| + parent_path = field_path.dup + field_name = parent_path.pop + parent = parent_path.inject(values) { |vals, key| vals[key] } + parent[:unauthorized_fields] ||= [] + parent[:unauthorized_fields] << field_name + end + values + end + + def method_missing(method, *args, &block) + if self.exposed_fields.include?(method) + model.public_send(method, *args, &block) + else + super + end + end + + def present(other_model, options={}) + Presenters.present(other_model, current_user, render_context, options) + end + + def authorized? + true + end + + def authorize!(*field_path, &block) + if authorized? + block.call + else + unauthorized_fields << field_path + nil + end + end + + private + + attr_reader :model, :current_user, :options, :render_context, :unauthorized_fields + end +end diff --git a/app/lib/presenters/component_presenter.rb b/app/lib/presenters/component_presenter.rb new file mode 100644 index 00000000..daec1821 --- /dev/null +++ b/app/lib/presenters/component_presenter.rb @@ -0,0 +1,43 @@ +module Presenters + class ComponentPresenter < BasePresenter + + alias_method :component, :model + + def default_options + { readings: nil } + end + + def exposed_fields + %i{sensor last_reading_at latest_value previous_value readings} + end + + def sensor + present(component.sensor) + end + + def latest_value + data = component.device.data + data[component.sensor_id.to_s] if data + end + + def previous_value + old_data = component.device.old_data + old_data[component.sensor_id.to_s] if old_data + end + + def readings + readings = options[:readings] + if readings + readings.map { |reading| format_reading(reading) }.compact + end + end + + private + + def format_reading(reading) + timestamp = reading[""] + value = reading[component.sensor_id.to_s] + { timestamp: timestamp, value: value } if value + end + end +end diff --git a/app/lib/presenters/device_presenter.rb b/app/lib/presenters/device_presenter.rb new file mode 100644 index 00000000..27565b53 --- /dev/null +++ b/app/lib/presenters/device_presenter.rb @@ -0,0 +1,94 @@ +module Presenters + class DevicePresenter < BasePresenter + alias_method :device, :model + + def default_options + { + with_owner: true, + with_postprocessing: true, + with_location: true, + never_authorized: false, + readings: nil + } + end + + def exposed_fields + %i{id uuid name description state system_tags user_tags last_reading_at created_at updated_at notify device_token mac_address postprocessing location data_policy hardware owner components} + end + + def notify + { + stopped_publishing: device.notify_stopped_publishing, + low_battery: device.notify_low_battery + } + end + + def location + if options[:with_location] + { + exposure: device.exposure, + elevation: device.elevation.try(:to_i) , + latitude: device.latitude, + longitude: device.longitude, + geohash: device.geohash, + city: device.city, + country_code: device.country_code, + country: device.country_name + } + end + end + + def data_policy + authorize!(:data_policy) do + { + is_private: device.is_private, + enable_forwarding: device.enable_forwarding, + precise_location: device.precise_location + } + end + end + + def hardware + { + name: device.hardware_name, + type: device.hardware_type, + version: device.hardware_version, + slug: device.hardware_slug, + last_status_message: authorize!(:hardware, :last_status_message) { device.hardware_info }, + } + end + + def owner + if options[:with_owner] && device.owner + return present(device.owner, with_devices: false) + end + end + + def postprocessing + device.postprocessing if options[:with_postprocessing] + end + + def device_token + authorize!(:device_token) { device.device_token } + end + + def mac_address + authorize!(:mac_address) { device.mac_address } + end + + def components + present(device.components, readings: options[:readings]) + end + + private + + def authorized? + !options[:never_authorized] && policy.show_private_info? + end + + def policy + @policy ||= DevicePolicy.new(current_user, device) + end + + end +end diff --git a/app/lib/presenters/measurement_presenter.rb b/app/lib/presenters/measurement_presenter.rb new file mode 100644 index 00000000..0c3cb98f --- /dev/null +++ b/app/lib/presenters/measurement_presenter.rb @@ -0,0 +1,10 @@ +module Presenters + class MeasurementPresenter < BasePresenter + + alias_method :measurement, :model + + def exposed_fields + %i{id name description unit uuid definition} + end + end +end diff --git a/app/lib/presenters/sensor_presenter.rb b/app/lib/presenters/sensor_presenter.rb new file mode 100644 index 00000000..0d355b80 --- /dev/null +++ b/app/lib/presenters/sensor_presenter.rb @@ -0,0 +1,14 @@ +module Presenters + class SensorPresenter < BasePresenter + + alias_method :sensor, :model + + def exposed_fields + %i{id parent_id name description unit created_at updated_at uuid datasheet unit_definition measurement tags} + end + + def measurement + present(sensor.measurement) + end + end +end diff --git a/app/lib/presenters/user_presenter.rb b/app/lib/presenters/user_presenter.rb new file mode 100644 index 00000000..351db598 --- /dev/null +++ b/app/lib/presenters/user_presenter.rb @@ -0,0 +1,42 @@ +module Presenters + class UserPresenter < BasePresenter + + alias_method :user, :model + + def default_options + { + with_devices: true + } + end + + def exposed_fields + %i{id uuid role username profile_picture url location email legacy_api_key devices created_at updated_at} + end + + def profile_picture + render_context&.profile_picture_url(user) + end + + def email + authorize!(:email) { user.email } + end + + def legacy_api_key + authorize!(:legacy_api_key) { user.legacy_api_key } + end + + def devices + present(user.devices) if options[:with_devices] + end + + private + + def authorized? + policy.show_private_info? + end + + def policy + @policy ||= UserPolicy.new(current_user, user) + end + end +end diff --git a/app/models/concerns/message_forwarding.rb b/app/models/concerns/message_forwarding.rb index 5136d71e..f32224c3 100644 --- a/app/models/concerns/message_forwarding.rb +++ b/app/models/concerns/message_forwarding.rb @@ -2,9 +2,9 @@ module MessageForwarding extend ActiveSupport::Concern - def forward_reading(device, reading) + def forward_readings(device, readings) if device.forward_readings? - MQTTForwardingJob.perform_later(device.id, reading) + MQTTForwardingJob.perform_later(device.id, readings: readings.map(&:stringify_keys)) end end diff --git a/app/models/device.rb b/app/models/device.rb index 2ce4d811..d2027378 100644 --- a/app/models/device.rb +++ b/app/models/device.rb @@ -204,6 +204,7 @@ def soft_state end end + # TODO Remove: Deprevated in API v1 def formatted_location(slim = false) { exposure: exposure, @@ -217,6 +218,7 @@ def formatted_location(slim = false) }.compact end + # TODO Remove: Deprevated in API v1 def formatted_data s = { sensors: [] @@ -259,6 +261,7 @@ def update_component_timestamps(timestamp, sensor_ids) end end + # TODO Remove: Deprevated in API v1 def data_policy(authorized=false) { is_private: is_private, @@ -267,6 +270,7 @@ def data_policy(authorized=false) } end + # TODO Remove: Deprevated in API v1 def hardware(authorized=false) { name: hardware_name, diff --git a/app/models/kairos.rb b/app/models/kairos.rb index b755b231..e7d41740 100644 --- a/app/models/kairos.rb +++ b/app/models/kairos.rb @@ -15,6 +15,8 @@ def self.query params rollup_value = params[:rollup].to_i rollup_unit = Kairos.get_timespan( params[:rollup].gsub(rollup_value.to_s,'') ) + limit = params[:limit]&.to_i + device = Device.find(params[:id]) if sensor_key = params[:sensor_key] @@ -26,6 +28,7 @@ def self.query params component = device.find_component_by_sensor_id(sensor_id) + unless component return { device_id: params[:id], @@ -44,6 +47,7 @@ def self.query params metrics = [{ tags: { device_id: params[:id] }, name: sensor_key, + limit: limit, aggregators: [ { name: function, @@ -54,7 +58,7 @@ def self.query params } } ] - }] + }.compact] data = { metrics: metrics, cache_time: 0 } diff --git a/app/models/raw_storer.rb b/app/models/raw_storer.rb index 89e5f144..ec903202 100644 --- a/app/models/raw_storer.rb +++ b/app/models/raw_storer.rb @@ -62,18 +62,11 @@ def store data, mac, version, ip, raise_errors=false device.update_columns(last_reading_at: parsed_ts, data: sql_data, state: 'has_published') end - forward_reading(device, data) + forward_readings(device, [sql_data]) rescue Exception => e success = false raise e if raise_errors end - - if !Rails.env.test? and device - begin - Redis.current.publish("data-received", renderer.render( partial: "v0/devices/device", locals: {device: @device, current_user: nil})) - rescue - end - end end end diff --git a/app/models/storer.rb b/app/models/storer.rb index 07fd1d6a..e8700c23 100644 --- a/app/models/storer.rb +++ b/app/models/storer.rb @@ -2,26 +2,26 @@ class Storer include DataParser::Storer include MessageForwarding - def store device, reading, do_update = true - begin - parsed_reading = Storer.parse_reading(device, reading) - kairos_publish(parsed_reading[:_data]) - - if do_update - update_device(device, parsed_reading[:parsed_ts], parsed_reading[:sql_data]) + def store device, readings + readings_to_forward = [] + readings.sort_by {|a| a['recorded_at']}.reverse.each_with_index do |reading, index| + begin + parsed_reading = Storer.parse_reading(device, reading) + kairos_publish(parsed_reading[:_data]) + readings_to_forward << parsed_reading[:sql_data] + if index == 0 + update_device_last_data(device, parsed_reading[:parsed_ts], parsed_reading[:sql_data]) + end + + rescue Exception => e + Sentry.capture_exception(e) + raise e if Rails.env.test? end - - forward_reading(device, reading) - - rescue Exception => e - Sentry.capture_exception(e) - raise e if Rails.env.test? end - - raise e unless e.nil? + forward_readings(device, readings_to_forward) end - def update_device(device, parsed_ts, sql_data) + def update_device_last_data(device, parsed_ts, sql_data) return if parsed_ts <= Time.at(0) device.transaction do device.lock! diff --git a/app/policies/device_policy.rb b/app/policies/device_policy.rb index 291f2d7d..5da4b678 100644 --- a/app/policies/device_policy.rb +++ b/app/policies/device_policy.rb @@ -23,15 +23,23 @@ def resolve end end + def show_private_info? + admin_or_owner? + end + def show? if record.is_private? - update? + admin_or_owner? else true end end def update? + admin_or_owner? + end + + def admin_or_owner? user.try(:is_admin?) || user == record.owner end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 63ba866c..743fc13b 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -23,4 +23,8 @@ def request_password_reset? def update_password? create? end + + def show_private_info? + update? + end end diff --git a/app/views/v001/devices/_device.jbuilder b/app/views/v001/devices/_device.jbuilder deleted file mode 100644 index b1ac8807..00000000 --- a/app/views/v001/devices/_device.jbuilder +++ /dev/null @@ -1,37 +0,0 @@ -json.(device, - :id, - :description, - :city, - :country, - :exposure -) - -json.merge! elevation: device.elevation.try(:to_f) -json.merge! title: device.name -json.merge! location: device.city -json.merge! geo_lat: device.latitude -json.merge! geo_lng: device.longitude -json.merge! created: device.created_at -json.merge! last_insert_datetime: device.last_insert_datetime #Device.find(device.id).last_reading_at - - -# last_insert_datetime - -# { -# "devices": [ -# { -# "id": "24", -# "title": "Pral2a", -# "description": "Test", -# "location": "Barcelona", -# "city": "Barcelona", -# "country": "Spain", -# "exposure": "outdoor", -# "elevation": "100.0", -# "geo_lat": "41.383180", -# "geo_long": "2.157960", -# "created": "2013-04-24 18:09:05", -# "last_insert_datetime": "2013-05-16 11:44:56" -# } -# ] -# } \ No newline at end of file diff --git a/app/views/v001/devices/current_user_index.jbuilder b/app/views/v001/devices/current_user_index.jbuilder deleted file mode 100644 index 4dfd8558..00000000 --- a/app/views/v001/devices/current_user_index.jbuilder +++ /dev/null @@ -1 +0,0 @@ -json.array! @devices, partial: 'device', as: :device diff --git a/app/views/v001/devices/index.jbuilder b/app/views/v001/devices/index.jbuilder deleted file mode 100644 index 64af761e..00000000 --- a/app/views/v001/devices/index.jbuilder +++ /dev/null @@ -1,3 +0,0 @@ -json.devices do - json.array! @devices, partial: 'device', as: :device -end diff --git a/app/views/v001/users/show.jbuilder b/app/views/v001/users/show.jbuilder deleted file mode 100644 index d916dfa6..00000000 --- a/app/views/v001/users/show.jbuilder +++ /dev/null @@ -1,42 +0,0 @@ -json.me do - json.(@user, - :id, - :username, - :city, - :email, - :location - ) - - json.merge! country: @user.country_name - json.merge! website: nil - json.merge! created: @user.created_at - - json.devices @user.devices, partial: 'v001/devices/device', as: :device - -end - -# "me": { -# "id": "5", -# "username": "Guillem", -# "city": "Barcelona", -# "country": "Spain", -# "website": "", -# "email": "g8i113m@gmail.com", -# "created": "2013-04-23 00:34:13", -# "devices": [ -# { -# "id": "24", -# "title": "Pral2a", -# "description": "Test", -# "location": "Barcelona", -# "city": "Barcelona", -# "country": "Spain", -# "exposure": "outdoor", -# "elevation": "100.0", -# "geo_lat": "41.383180", -# "geo_long": "2.157960", -# "created": "2013-04-24 18:09:05", -# "last_insert_datetime": "2013-05-16 11:44:56" -# } -# ] -# } \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index e84ea258..2bb48487 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,10 @@ get 'password_reset/:token', to: 'sessions#password_reset_landing', as: 'password_reset' end + api_version(module: "V1", path: { value: "v1" }, header: {name: "Accept", value: "application/vnd.smartcitizen; version=1"}, defaults: { format: :json }) do + resources :devices + end + api_version(module: "V0", path: {value: "v0"}, header: {name: "Accept", value: "application/vnd.smartcitizen; version=0"}, default: true, defaults: { format: :json }) do # devices resources :devices do diff --git a/db/migrate/20241113155952_remove_null_strings_from_measurement_units.rb b/db/migrate/20241113155952_remove_null_strings_from_measurement_units.rb new file mode 100644 index 00000000..9b5e9195 --- /dev/null +++ b/db/migrate/20241113155952_remove_null_strings_from_measurement_units.rb @@ -0,0 +1,8 @@ +class RemoveNullStringsFromMeasurementUnits < ActiveRecord::Migration[6.1] + def up + execute "UPDATE measurements SET unit = NULL WHERE unit = 'NULL'" + execute "UPDATE sensors SET unit = NULL WHERE unit = 'NULL'" + end + + def down; end +end diff --git a/db/schema.rb b/db/schema.rb index cc624da1..435d9451 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_10_14_052837) do +ActiveRecord::Schema.define(version: 2024_11_13_155952) do # These are extensions that must be enabled in order to support this database enable_extension "adminpack" diff --git a/spec/controllers/v1/devices_controller_spec.rb b/spec/controllers/v1/devices_controller_spec.rb new file mode 100644 index 00000000..0d508386 --- /dev/null +++ b/spec/controllers/v1/devices_controller_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe V1::DevicesController do + skip { is_expected.to permit(:name,:description,:mac_address,:latitude,:longitude,:elevation,:exposure,:meta,:user_tags).for(:create) } +end diff --git a/spec/jobs/mqtt_forwarding_job_spec.rb b/spec/jobs/mqtt_forwarding_job_spec.rb index 24b273d1..24086520 100644 --- a/spec/jobs/mqtt_forwarding_job_spec.rb +++ b/spec/jobs/mqtt_forwarding_job_spec.rb @@ -6,7 +6,7 @@ let(:device) { create(:device) } - let(:reading) { double(:reading) } + let(:readings) { double(:readings) } let(:mqtt_client) { double(:mqtt_client).tap do |mqtt_client| @@ -18,27 +18,27 @@ double(:device_json) } - let(:renderer) { - double(:renderer).tap do |renderer| - allow(renderer).to receive(:render).and_return(device_json) + let(:device_representation) { + double(:device_representation).tap do |device_representation| + allow(device_representation).to receive(:to_json).and_return(device_json) end } let(:forwarder) { double(:forwarder).tap do |forwarder| - allow(forwarder).to receive(:forward_reading) + allow(forwarder).to receive(:forward_readings) end } before do allow(MQTTClientFactory).to receive(:create_client).and_return(mqtt_client) - allow_any_instance_of(ActionController::Base).to receive(:view_context).and_return(renderer) + allow(Presenters).to receive(:present).and_return(device_representation) allow(MQTTForwarder).to receive(:new).and_return(forwarder) allow_any_instance_of(Device).to receive(:forwarding_token).and_return(forwarding_token) end it "creates an mqtt client with a clean session and no client id" do - MQTTForwardingJob.perform_now(device.id, reading) + MQTTForwardingJob.perform_now(device.id, readings: readings) expect(MQTTClientFactory).to have_received(:create_client).with({ clean_session: true, client_id: nil @@ -46,29 +46,22 @@ end it "creates a forwarder with the mqtt client" do - MQTTForwardingJob.perform_now(device.id, reading) + MQTTForwardingJob.perform_now(device.id, readings: readings) expect(MQTTForwarder).to have_received(:new).with(mqtt_client) end - it "renders the device json for the given device, as an unauthorized user" do - MQTTForwardingJob.perform_now(device.id, reading) - expect(renderer).to have_received(:render).with({ - partial: "v0/devices/device", - locals: { - device: device.reload, - current_user: nil, - slim_owner: true - } - }) + it "renders the device json for the given device and reading, as the device owner" do + MQTTForwardingJob.perform_now(device.id, readings: readings) + expect(Presenters).to have_received(:present).with(device, device.owner, nil, readings: readings) end it "forwards using the device's id and forwarding token, with the rendered json payload" do - MQTTForwardingJob.perform_now(device.id, reading) - expect(forwarder).to have_received(:forward_reading).with(forwarding_token, device.id, device_json) + MQTTForwardingJob.perform_now(device.id, readings: readings) + expect(forwarder).to have_received(:forward_readings).with(forwarding_token, device.id, device_json) end it "disconnects the MQTT client" do - MQTTForwardingJob.perform_now(device.id, reading) + MQTTForwardingJob.perform_now(device.id, readings: readings) expect(mqtt_client).to have_received(:disconnect) end diff --git a/spec/models/raw_storer_spec.rb b/spec/models/raw_storer_spec.rb index 989e1ab2..69772fb7 100644 --- a/spec/models/raw_storer_spec.rb +++ b/spec/models/raw_storer_spec.rb @@ -92,7 +92,8 @@ def to_ts(time) context "when the device allows forwarding" do it "forwards the message" do allow_any_instance_of(Device).to receive(:forward_readings?).and_return(true) - expect(MQTTForwardingJob).to receive(:perform_later).with(device.id, json) + # TODO assert that the correct arguments are called after refactoring device representations + expect(MQTTForwardingJob).to receive(:perform_later) storer.store(json, device.mac_address, "1.1-0.9.0-A", "127.0.0.1", true) end end diff --git a/spec/models/storer_spec.rb b/spec/models/storer_spec.rb index 43923a15..5de1fb04 100644 --- a/spec/models/storer_spec.rb +++ b/spec/models/storer_spec.rb @@ -20,6 +20,13 @@ 'sensors'=> [{ 'id'=> sensor.id, 'value'=>21 }] } + # TODO get rid of this fucked-up intermediate representation + @sql_data = { + "" => Time.parse(@data["recorded_at"]), + "#{sensor.id}_raw" => 21, + sensor.id => component.calibrated_value(21) + } + sensor_key = device.find_sensor_key_by_id(sensor.id) normalized_value = component.normalized_value((Float(@data['sensors'][0]['value']))) calibrated_value = component.calibrated_value(normalized_value) @@ -44,7 +51,7 @@ # model/storer.rb is not using Kairos, but Redis -> Telnet # expect(Kairos).to receive(:http_post_to).with("/datapoints", @karios_data) expect do - storer.store(device, @data) + storer.store(device, [@data]) end.not_to raise_error end @@ -53,13 +60,13 @@ Time.parse(@data['recorded_at']), [sensor.id] ) - storer.store(device, @data) + storer.store(device, [@data]) end skip 'updates device without touching updated_at' do updated_at = device.updated_at - storer.store(device, @data) + storer.store(device, [@data]) expect(device.reload.updated_at).to eq(updated_at) @@ -75,10 +82,10 @@ double(:device_json) } - it "forwards the message with the forwarding token and the device's id" do + it "forwards the readings for the device, ensuring reading keys are passed as strings" do allow(device).to receive(:forward_readings?).and_return(true) - expect(MQTTForwardingJob).to receive(:perform_later).with(device.id, @data) - storer.store(device, @data) + expect(MQTTForwardingJob).to receive(:perform_later).with(device.id, readings: [@sql_data.stringify_keys]) + storer.store(device, [@data]) end end @@ -86,7 +93,7 @@ it "does not forward the message" do allow(device).to receive(:forward_readings?).and_return(false) expect(MQTTForwardingJob).not_to receive(:perform_later) - storer.store(device, @data) + storer.store(device, [@data]) end end end @@ -103,11 +110,11 @@ it 'does raise error' do expect(Kairos).not_to receive(:http_post_to).with("/datapoints", anything) - expect{ storer.store(device, @bad_data) }.to raise_error(ArgumentError) + expect{ storer.store(device, [@bad_data]) }.to raise_error(ArgumentError) end it 'does not update device' do - expect{ storer.store(device, @bad_data) }.to raise_error(ArgumentError) + expect{ storer.store(device, [@bad_data]) }.to raise_error(ArgumentError) expect(device.reload.last_reading_at).to eq(nil) expect(device.reload.data).to eq(nil) diff --git a/spec/presenters/component_presenter_spec.rb b/spec/presenters/component_presenter_spec.rb new file mode 100644 index 00000000..fb0a74bd --- /dev/null +++ b/spec/presenters/component_presenter_spec.rb @@ -0,0 +1,93 @@ +require "rails_helper" +describe Presenters::ComponentPresenter do + + let(:sensor_presentation) { + double(:sensor_presentation) + } + + let(:component) { + FactoryBot.create(:component) + } + + let(:current_user) { + FactoryBot.create(:user) + } + + let(:render_context) { + double(:render_context) + } + + let(:options) { + {} + } + + subject(:presenter) { Presenters::ComponentPresenter.new(component, current_user, render_context, options) } + + it "exposes the last_reading_at date" do + expect(presenter.as_json[:last_reading_at]).to eq(component.last_reading_at) + end + + it "exposes a presentation of the sensor" do + allow(Presenters).to receive(:present).with(component.sensor, current_user, render_context, {}).and_return(sensor_presentation) + expect(presenter.as_json[:sensor]).to eq(sensor_presentation) + end + + context "when the component's device has data" do + it "returns the data for the corresponding sensor as the latest_value" do + allow(component.device).to receive(:data).and_return({ + component.sensor_id.to_s => 123.0 + }) + expect(presenter.as_json[:latest_value]).to eq(123.0) + end + end + + context "when the component's device has no data" do + it "has no latest_value" do + expect(presenter.as_json[:latest_value]).to eq(nil) + end + end + + context "when the component's device has old_data" do + it "returns the old data for the corresponding sensor as the previous_value" do + allow(component.device).to receive(:old_data).and_return({ + component.sensor_id.to_s => 246.0 + }) + expect(presenter.as_json[:previous_value]).to eq(246.0) + end + end + + context "when the component's device has no old_data" do + it "has no previous_value" do + expect(presenter.as_json[:previous_value]).to eq(nil) + end + end + + context "when readings are supplied" do + let(:reading_timestamp) { Time.now - 6.hours } + let(:reading_value) { 1234.1 } + let(:options) { + { + readings: [ + #TODO this particular reading format needs to be refactored out + { + "" => reading_timestamp, + "#{component.sensor_id}" => reading_value, + "#{component.sensor_id + 1}" => 54321.0 + } + ] + } + } + + it "returns the readings formatted with timestamp and value" do + expect(presenter.as_json[:readings]).to eq([ + { timestamp: reading_timestamp, value: reading_value } + ]) + end + end + + context "when readings are not supplied" do + it "has no readings" do + expect(presenter.as_json[:readings]).to eq(nil) + end + end +end diff --git a/spec/presenters/device_presenter_spec.rb b/spec/presenters/device_presenter_spec.rb new file mode 100644 index 00000000..2a89ebc6 --- /dev/null +++ b/spec/presenters/device_presenter_spec.rb @@ -0,0 +1,267 @@ +require "rails_helper" +describe Presenters::DevicePresenter do + + let(:owner) { + FactoryBot.create(:user) + } + + let(:components) { + double(:components) + } + + let(:device) { + FactoryBot.create(:device, owner: owner).tap do |device| + allow(device).to receive(:components).and_return(components) + end + } + + let(:current_user) { + FactoryBot.create(:user) + } + + let(:render_context) { + double(:render_context) + } + + let(:options) { + { readings: readings } + } + + let(:readings) { + double(:readings) + } + + let(:owner_presentation) { + double(:owner_presentation) + } + + let(:components_presentation) { + double(:components_presentation) + } + + let(:show_private_info) { false } + + let(:device_policy) { + double(:device_policy).tap do |device_policy| + allow(device_policy).to receive(:show_private_info?).and_return(show_private_info) + end + } + + + before do + allow(DevicePolicy).to receive(:new).and_return(device_policy) + + allow(Presenters).to receive(:present) do |model| + case model + when owner + owner_presentation + when components + components_presentation + end + end + end + + subject(:presenter) { Presenters::DevicePresenter.new(device, current_user, render_context, options) } + + it "exposes the id" do + expect(presenter.as_json[:id]).to eq(device.id) + end + + it "exposes the uuid" do + expect(presenter.as_json[:uuid]).to eq(device.uuid) + end + + it "exposes the name" do + expect(presenter.as_json[:name]).to eq(device.name) + end + + it "exposes the description" do + expect(presenter.as_json[:description]).to eq(device.description) + end + + it "exposes the state" do + expect(presenter.as_json[:state]).to eq(device.state) + end + + it "exposes the system_tags" do + expect(presenter.as_json[:system_tags]).to eq(device.system_tags) + end + + it "exposes the user_tags" do + expect(presenter.as_json[:user_tags]).to eq(device.user_tags) + end + + it "exposes the last_reading_at date" do + expect(presenter.as_json[:last_reading_at]).to eq(device.last_reading_at) + end + + it "exposes the created_at date" do + expect(presenter.as_json[:created_at]).to eq(device.created_at) + end + + it "exposes the updated_at date" do + expect(presenter.as_json[:updated_at]).to eq(device.updated_at) + end + + it "includes the notification statuses" do + expect(presenter.as_json[:notify][:stopped_publishing]).to eq(device.notify_stopped_publishing) + expect(presenter.as_json[:notify][:low_battery]).to eq(device.notify_low_battery) + end + + it "includes public hardware info" do + expect(presenter.as_json[:hardware][:name]).to eq(device.hardware_name) + expect(presenter.as_json[:hardware][:type]).to eq(device.hardware_type) + expect(presenter.as_json[:hardware][:version]).to eq(device.hardware_version) + expect(presenter.as_json[:hardware][:slug]).to eq(device.hardware_slug) + end + + context "by default" do + it "includes the location information" do + expect(presenter.as_json[:location][:exposure]).to eq(device.exposure) + expect(presenter.as_json[:location][:elevation]).to eq(device.elevation&.to_i) + expect(presenter.as_json[:location][:latitude]).to eq(device.latitude) + expect(presenter.as_json[:location][:longitude]).to eq(device.longitude) + expect(presenter.as_json[:location][:geohash]).to eq(device.geohash) + expect(presenter.as_json[:location][:city]).to eq(device.city) + expect(presenter.as_json[:location][:country]).to eq(device.country_name) + end + + it "includes the postprocessing information" do + expect(presenter.as_json[:postprocessing]).to eq(device.postprocessing) + end + + it "includes a presentation of the owner, without associated devices" do + expect(presenter.as_json[:owner]).to eq(owner_presentation) + expect(Presenters).to have_received(:present).with(device.owner, current_user, render_context, with_devices: false) + end + + it "includes a presentation of the components, passing the readings as an option" do + expect(presenter.as_json[:components]).to eq(components_presentation) + expect(Presenters).to have_received(:present).with(device.components, current_user, render_context, readings: readings) + end + end + + context "when with_location is false" do + let(:options) { { with_location: false } } + it "does not include the location information" do + expect(presenter.as_json[:location]).to be(nil) + end + end + + + context "when with_postprocessing is false" do + let(:options) { { with_postprocessing: false } } + it "does not include the postprocessing information" do + expect(presenter.as_json[:postprocessing]).to be(nil) + end + end + + context "when with_owner is false" do + let(:options) { { with_owner: false } } + it "does not include the owner" do + expect(presenter.as_json[:owner]).to be(nil) + expect(Presenters).not_to have_received(:present).with(device.owner, current_user, render_context, with_devices: false) + end + end + + context "when the user is authorized to view the device's private info" do + + let(:show_private_info) { true } + context "when the never_authorized option is true" do + + let(:options) { { never_authorized: true } } + + it "does not include the hardware status message" do + expect(presenter.as_json[:hardware][:last_status_message]).to be(nil) + end + + it "includes hardware status_message in the hardware unauthorized_fields" do + expect(presenter.as_json[:hardware][:unauthorized_fields]).to include(:last_status_message) + end + + it "does not include the data_policy" do + expect(presenter.as_json[:data_policy]).to be(nil) + end + + it "includes the data_policy in the unauthorized_fields" do + expect(presenter.as_json[:unauthorized_fields]).to include(:data_policy) + end + + it "does not include the device_token" do + expect(presenter.as_json[:device_token]).to be(nil) + end + + it "includes the device_token in the unauthorized_fields" do + expect(presenter.as_json[:unauthorized_fields]).to include(:device_token) + end + + it "does not include the mac_address" do + expect(presenter.as_json[:mac_address]).to be(nil) + end + + it "includes the mac_address in the unauthorized_fields" do + expect(presenter.as_json[:unauthorized_fields]).to include(:mac_address) + end + end + + + context "when the never_authorized option is false" do + let(:options) { { never_authorized: false } } + + it "includes the hardware status message" do + expect(presenter.as_json[:hardware][:last_status_message]).to eq(device.hardware_info) + end + + it "does not include hardware unauthorized_fields" do + expect(presenter.as_json[:hardware]).not_to include(:unauthorized_fields) + end + + it "includes the data_policy" do + expect(presenter.as_json[:data_policy][:is_private]).to eq(device.is_private) + expect(presenter.as_json[:data_policy][:enable_forwarding]).to eq(device.enable_forwarding) + expect(presenter.as_json[:data_policy][:precise_location]).to eq(device.precise_location) + end + + it "includes the device_token" do + expect(presenter.as_json[:device_token]).to eq(device.device_token) + end + + it "includes the mac_address" do + expect(presenter.as_json[:mac_address]).to eq(device.mac_address) + end + + it "does not include unauthorized_fields" do + expect(presenter.as_json).not_to include(:unauthorized_fields) + end + end + end + + context "when the user is not authorized to view the device's private info" do + let(:show_private_info) { false } + it "does not include the hardware status message" do + expect(presenter.as_json[:hardware][:last_status_message]).to be(nil) + end + + it "includes hardware status_message in the hardware unauthorized_fields" do + expect(presenter.as_json[:hardware][:unauthorized_fields]).to include(:last_status_message) + end + + it "does not include the data_policy" do + expect(presenter.as_json[:data_policy]).to be(nil) + end + + it "includes the data_policy in the unauthorized_fields" do + expect(presenter.as_json[:unauthorized_fields]).to include(:data_policy) + end + + it "does not include the mac_address" do + expect(presenter.as_json[:mac_address]).to be(nil) + end + + it "includes the mac_address in the unauthorized_fields" do + expect(presenter.as_json[:unauthorized_fields]).to include(:mac_address) + end + end + + +end diff --git a/spec/presenters/measurement_presenter_spec.rb b/spec/presenters/measurement_presenter_spec.rb new file mode 100644 index 00000000..6c6ed5c3 --- /dev/null +++ b/spec/presenters/measurement_presenter_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" +describe Presenters::MeasurementPresenter do + + let(:measurement) { + FactoryBot.create(:measurement) + } + + let(:current_user) { + FactoryBot.create(:user) + } + + let(:render_context) { + double(:render_context) + } + + let(:options) { + {} + } + + subject(:presenter) { Presenters::MeasurementPresenter.new(measurement, current_user, render_context, options) } + + it "exposes the id" do + expect(presenter.as_json[:id]).to eq(measurement.id) + end + + it "exposes the name" do + expect(presenter.as_json[:name]).to eq(measurement.name) + end + + it "exposes the description" do + expect(presenter.as_json[:description]).to eq(measurement.description) + end + + it "exposes the unit" do + expect(presenter.as_json[:unit]).to eq(measurement.unit) + end + + it "exposes the uuid" do + expect(presenter.as_json[:uuid]).to eq(measurement.uuid) + end + + it "exposes the description" do + expect(presenter.as_json[:description]).to eq(measurement.description) + end +end diff --git a/spec/presenters/sensor_presenter_spec.rb b/spec/presenters/sensor_presenter_spec.rb new file mode 100644 index 00000000..a4566b7f --- /dev/null +++ b/spec/presenters/sensor_presenter_spec.rb @@ -0,0 +1,74 @@ +require "rails_helper" +describe Presenters::SensorPresenter do + + let(:sensor) { + FactoryBot.create(:sensor) + } + + let(:current_user) { + FactoryBot.create(:user) + } + + let(:measurement_presentation) { + double(:measurement_presentation) + } + + let(:render_context) { + double(:render_context) + } + + let(:options) { + {} + } + + subject(:presenter) { Presenters::SensorPresenter.new(sensor, current_user, render_context, options) } + + it "exposes the id" do + expect(presenter.as_json[:id]).to eq(sensor.id) + end + + it "exposes the parent_id" do + expect(presenter.as_json[:parent_id]).to eq(sensor.parent_id) + end + + it "exposes the name" do + expect(presenter.as_json[:name]).to eq(sensor.name) + end + + it "exposes the description" do + expect(presenter.as_json[:description]).to eq(sensor.description) + end + + it "exposes the unit" do + expect(presenter.as_json[:unit]).to eq(sensor.unit) + end + + it "exposes the created_at date" do + expect(presenter.as_json[:created_at]).to eq(sensor.created_at) + end + + it "exposes the updated_at date" do + expect(presenter.as_json[:updated_at]).to eq(sensor.updated_at) + end + + it "exposes the uuid" do + expect(presenter.as_json[:uuid]).to eq(sensor.uuid) + end + + it "exposes the datasheet" do + expect(presenter.as_json[:datasheet]).to eq(sensor.datasheet) + end + + it "exposes the unit_definition" do + expect(presenter.as_json[:unit_definition]).to eq(sensor.unit_definition) + end + + it "exposes the tags" do + expect(presenter.as_json[:tags]).to eq(sensor.tags) + end + + it "exposes a presentation of the measurement" do + allow(Presenters).to receive(:present).with(sensor.measurement, current_user, render_context, {}).and_return(measurement_presentation) + expect(presenter.as_json[:measurement]).to eq(measurement_presentation) + end +end diff --git a/spec/presenters/user_presenter_spec.rb b/spec/presenters/user_presenter_spec.rb new file mode 100644 index 00000000..74aad809 --- /dev/null +++ b/spec/presenters/user_presenter_spec.rb @@ -0,0 +1,144 @@ +require "rails_helper" +describe Presenters::UserPresenter do + + let(:user) { + FactoryBot.create(:user) + } + + let(:current_user) { + FactoryBot.create(:user) + } + + let(:device_1) { + FactoryBot.create(:device) + } + + let(:device_2) { + FactoryBot.create(:device) + } + + let(:device_1_presentation) { + double(:device_1_presentation) + } + + let(:device_2_presentation) { + double(:device_2_presentation) + } + + let(:user) { + FactoryBot.create(:user, devices: [device_1, device_2]) + } + + let(:profile_picture_url) { + double(:profile_picture_url) + } + + let(:render_context) { + double(:render_context).tap do |render_context| + allow(render_context).to receive(:profile_picture_url).and_return(profile_picture_url) + end + } + + let(:options) { + {} + } + + let(:show_private_info) do + false + end + + let(:user_policy) { + double(:user_policy).tap do |user_policy| + allow(user_policy).to receive(:show_private_info?).and_return(show_private_info) + end + } + + before do + allow(UserPolicy).to receive(:new).and_return(user_policy) + allow(Presenters).to receive(:present).and_return([device_1_presentation, device_2_presentation]) + end + + subject(:presenter) { Presenters::UserPresenter.new(user, current_user, render_context, options) } + + it "exposes the id" do + expect(presenter.as_json[:id]).to eq(user.id) + end + + it "exposes the uuid" do + expect(presenter.as_json[:uuid]).to eq(user.uuid) + end + + it "exposes the role" do + expect(presenter.as_json[:role]).to eq(user.role) + end + + it "exposes the username" do + expect(presenter.as_json[:username]).to eq(user.username) + end + + it "gets the profile_picture_url from the render_context" do + expect(presenter.as_json[:profile_picture]).to eq(profile_picture_url) + expect(render_context).to have_received(:profile_picture_url).with(user) + end + + it "exposes the location" do + expect(presenter.as_json[:location]).to eq(user.location) + end + + it "exposes the created_at date" do expect(presenter.as_json[:created_at]).to eq(user.created_at) + end + + it "exposes the updated_at date" do + expect(presenter.as_json[:updated_at]).to eq(user.updated_at) + end + + context "when the current user is not authorized" do + it "does not show the email" do + expect(presenter.as_json[:email]).to eq(nil) + end + + it "includes the email field in the unauthorized_fields collection" do + expect(presenter.as_json[:unauthorized_fields]).to include(:email) + end + + it "does not show the legacy API key" do + expect(presenter.as_json[:legacy_api_key]).to eq(nil) + end + + it "includes the legacy API key field in the unauthorized fields collection" do + expect(presenter.as_json[:unauthorized_fields]).to include(:legacy_api_key) + end + end + + context "when the current user is authorized" do + let(:show_private_info) { true } + + it "shows the email" do + expect(presenter.as_json[:email]).to eq(user.email) + end + + it "shows the legacy API key" do + expect(presenter.as_json[:legacy_api_key]).to eq(user.legacy_api_key) + end + + it "does not include any unauthorized fields" do + expect(presenter.as_json).not_to include(:unauthorized_fields) + end + end + + context "by default" do + it "includes presentations of the user's devices" do + + expect(presenter.as_json[:devices]).to eq([device_1_presentation, device_2_presentation]) + expect(Presenters).to have_received(:present).with([device_1, device_2], current_user, render_context, options) + end + end + + context "when the with_devices option is false" do + let(:options) { { with_devices: false }} + + it "does not include devices" do + expect(presenter.as_json[:devices]).to eq(nil) + end + end +end diff --git a/spec/requests/v0/password_resets_spec.rb b/spec/requests/v0/password_resets_spec.rb index 13c1b440..1cfd239c 100644 --- a/spec/requests/v0/password_resets_spec.rb +++ b/spec/requests/v0/password_resets_spec.rb @@ -117,7 +117,6 @@ it "can reset password with valid token" do expect(user.authenticate('newpass')).to be_falsey j = api_put "password_resets/#{user.password_reset_token}", { password: 'newpass' } - p response expect(j["username"]).to eq(user.username) expect(response.status).to eq(200) diff --git a/spec/requests/v1/devices_spec.rb b/spec/requests/v1/devices_spec.rb new file mode 100644 index 00000000..b782d580 --- /dev/null +++ b/spec/requests/v1/devices_spec.rb @@ -0,0 +1,225 @@ + +require 'rails_helper' + +describe V0::DevicesController do + + let(:application) { create :application } + let(:user) { create :user } + let(:user2) { create :user } + let(:token) { create :access_token, application: application, resource_owner_id: user.id } + let(:device) { create(:device) } + let(:admin) { create :admin } + let(:admin_token) { create :access_token, application: application, resource_owner_id: admin.id } + + describe "GET /devices" do + + it "can be filered" + + it "is paginated" + + it "returns all the devices" do + first = create(:device) + second = create(:device) + json = api_get 'devices', version: 1 + expect(response.status).to eq(200) + expect(json.length).to eq(2) + # expect(json[0]['name']).to eq(first.name) + # expect(json[1]['name']).to eq(second.name) + expect(json[0].keys).to eq(%w(id uuid name description state system_tags user_tags last_reading_at created_at updated_at notify device_token postprocessing location data_policy hardware owner data experiment_ids)) + end + + describe "when not logged in" do + it 'does not show private devices' do + device = create(:device, owner: user, is_private: false) + device1 = create(:device, owner: user, is_private: true) + device2 = create(:device, owner: user, is_private: true) + expect(Device.count).to eq(3) + + j = api_get "devices/", version: 1 + expect(response.status).to eq(200) + expect(j.count).to eq(1) + expect(j[0]['id']).to eq(device.id) + end + end + + describe "when logged in as a normal user" do + it 'shows the user his devices, even though they are private' do + device1 = create(:device, owner: user, is_private: false) + device2 = create(:device, owner: user, is_private: true) + device3 = create(:device, owner: user2, is_private: true) + expect(Device.count).to eq(3) + + j = api_get "devices/", { access_token: token.token }, 1 + expect(response.status).to eq(200) + expect(j.count).to eq(2) + expect(j[0]['id']).to be_in([device1.id, device2.id]) + end + end + + describe "with near" do + + let!(:barcelona) { create(:device) } + let!(:paris) { create(:device, latitude: 48.8582606, longitude: 2.2923184) } + let!(:manchester) { create(:device, latitude: 53.4630589, longitude: -2.2935288) } + let!(:cape_town) { create(:device, latitude: -33.9080317, longitude: 18.4154827) } + + let!(:london_coordinates) { "51.5286416,-0.1015987" } + + it "returns devices order with default distance" do + json = api_get "devices?near=#{london_coordinates}", version: 1 + + # puts Geocoder::Calculations.distance_between( london_coordinates.split(','), [barcelona.latitude, barcelona.longitude]) + # puts Geocoder::Calculations.distance_between( london_coordinates.split(','), [paris.latitude, paris.longitude]) + # puts Geocoder::Calculations.distance_between( london_coordinates.split(','), [manchester.latitude, manchester.longitude]) + + expect(response.status).to eq(200) + expect(json.map{|j| j['id']}).to eq([manchester, paris, barcelona].map(&:id)) + end + + it "returns devices order with custom distance" do + json = api_get "devices?near=#{london_coordinates}&within=1000000", version: 1 + expect(response.status).to eq(200) + expect(json.map{|j| j['id']}).to eq([manchester, paris, barcelona, cape_town].map(&:id)) + end + + it "fails for invalid near" do + json = api_get "devices?near=13", version: 1 + expect(response.status).to eq(400) + end + + it "allows searching by id" do + json = api_get "devices?q[id_eq]=1", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by name" do + json = api_get "devices?q[name_eq]=Name", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by description" do + json = api_get "devices?q[description_eq]=Desc", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by created_at" do + json = api_get "devices?q[created_at_lt]=2023-09-26", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by updated_at" do + json = api_get "devices?q[updated_at_lt]=2023-09-26", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by last_reading_at" do + json = api_get "devices?q[last_reading_at_lt]=2023-09-26", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by state" do + json = api_get "devices?q[state_eq]=state", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by geohash" do + json = api_get "devices?q[geohash_eq]=geohash", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by uuid" do + json = api_get "devices?q[uuid_eq]=uuid", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by owner id" do + json = api_get "devices?q[owner_id_eq]=1", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by owner username" do + json = api_get "devices?q[owner_username_eq]=test", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by tag name" do + json = api_get "devices?q[tags_name_eq]=test", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by presence of postprocessing" do + json = api_get "devices?q[postprocessing_id_not_null]=1", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by postprocessing id" do + json = api_get "devices?q[postprocessing_id_eq]=1", version: 1 + expect(response.status).to eq(200) + end + + it "allows searching by mac address by admins" do + json = api_get "devices?q[mac_address_eq]=00:00:00:00:00:00&access_token=#{admin_token.token}", version: 1 + expect(response.status).to eq(200) + end + + it "does not allow searching by mac address by non-admins" do + json = api_get "devices?q[mac_address_eq]=00:00:00:00:00:00", version: 1 + expect(response.status).to eq(400) + expect(json["status"]).to eq(400) + end + + it "does not allow searching on disallowed parameters" do + json = api_get "devices?q[disallowed_eq]=1", version: 1 + expect(response.status).to eq(400) + expect(json["status"]).to eq(400) + end + end + end + + describe "GET /devices/:id" do + + it "returns a device" do + j = api_get "devices/#{device.id}", version: 1 + expect(j['id']).to eq(device.id) + expect(response.status).to eq(200) + end + + it "returns 404 if device not found" do + j = api_get 'devices/100', version: 1 + expect(j['id']).to eq('record_not_found') + expect(response.status).to eq(404) + end + + it 'does not show a private device' do + device = create(:device, owner: user, is_private: true) + j = api_get "devices/#{device.id}", version: 1 + expect(j['id']).to eq("forbidden") + expect(response.status).to eq(403) + end + + it 'shows a non_private device' do + device = create(:device, owner: user, is_private: false) + j = api_get "devices/#{device.id}", version: 1 + expect(j['id']).to eq(device.id) + expect(response.status).to eq(200) + end + end + + describe "states" do + + before(:each) do + @not_configured = create(:device, mac_address: nil) + @never_published = create(:device, mac_address: '2a:f3:e6:d9:76:84') + @has_published = create(:device, mac_address: '2a:f3:e6:d9:76:86', data: {'a': 'b'}) + end + + %w(not_configured never_published has_published).each do |state| + it "filters by q[state_eq] #{state}" do + json = api_get "devices?q[state_eq]=#{state}", version: 1 + expect(response.status).to eq(200) + expect(json.map{|j| j['id']}).to eq([ instance_variable_get("@#{state}") ].map(&:id)) + end + end + + end +end