diff --git a/Gemfile b/Gemfile index 8444d6748..b737e0080 100644 --- a/Gemfile +++ b/Gemfile @@ -71,6 +71,8 @@ gem 'rails-assets-markdown-it', source: 'https://rails-assets.org' gem 'cocoon' +gem 'graphql', '~> 1.6.3' + group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug' @@ -106,6 +108,7 @@ end group :development do # Access an IRB console on exception pages or by using <%= console %> in views gem 'web-console', '3.3.0' + gem 'graphiql-rails', '~> 1.4.1' end eval_gemfile './Gemfile_custom' diff --git a/Gemfile.lock b/Gemfile.lock index 800b14763..9a37210bc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -176,8 +176,10 @@ GEM geocoder (1.4.3) globalid (0.3.7) activesupport (>= 4.1.0) + graphiql-rails (1.4.1) + rails + graphql (1.6.3) groupdate (3.2.0) - activesupport (>= 3) gyoku (1.3.1) builder (>= 2.1.2) hashie (3.5.5) @@ -504,6 +506,8 @@ DEPENDENCIES foundation-rails (~> 6.2.4.0) foundation_rails_helper (~> 2.0.0) fuubar + graphiql-rails (~> 1.4.1) + graphql (~> 1.6.3) groupdate (~> 3.2.0) i18n-tasks (~> 0.9.15) initialjs-rails (= 0.2.0.4) diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb new file mode 100644 index 000000000..762239dc8 --- /dev/null +++ b/app/controllers/graphql_controller.rb @@ -0,0 +1,52 @@ +class GraphqlController < ApplicationController + + skip_before_action :verify_authenticity_token + skip_authorization_check + + class QueryStringError < StandardError; end + + def query + begin + if query_string.nil? then raise GraphqlController::QueryStringError end + response = consul_schema.execute query_string, variables: query_variables + render json: response, status: :ok + rescue GraphqlController::QueryStringError + render json: { message: 'Query string not present' }, status: :bad_request + rescue JSON::ParserError + render json: { message: 'Error parsing JSON' }, status: :bad_request + rescue GraphQL::ParseError + render json: { message: 'Query string is not valid JSON' }, status: :bad_request + rescue + unless Rails.env.production? then raise end + end + end + + private + + def consul_schema + api_types = GraphQL::ApiTypesCreator.create(API_TYPE_DEFINITIONS) + query_type = GraphQL::QueryTypeCreator.create(api_types) + + GraphQL::Schema.define do + query query_type + max_depth 8 + max_complexity 2500 + end + end + + def query_string + if request.headers["CONTENT_TYPE"] == 'application/graphql' + request.body.string # request.body.class => StringIO + else + params[:query] + end + end + + def query_variables + if params[:variables].blank? || params[:variables] == 'null' + {} + else + JSON.parse(params[:variables]) + end + end +end diff --git a/app/models/comment.rb b/app/models/comment.rb index aa3f27e24..ffa02d09c 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,5 +1,7 @@ class Comment < ActiveRecord::Base include Flaggable + include HasPublicAuthor + include Graphqlable acts_as_paranoid column: :hidden_at include ActsAsParanoidAliases @@ -24,6 +26,12 @@ class Comment < ActiveRecord::Base scope :with_visible_author, -> { joins(:user).where("users.hidden_at IS NULL") } scope :not_as_admin_or_moderator, -> { where("administrator_id IS NULL").where("moderator_id IS NULL")} scope :sort_by_flags, -> { order(flags_count: :desc, updated_at: :desc) } + scope :public_for_api, -> do + where(%{(comments.commentable_type = 'Debate' and comments.commentable_id in (?)) or + (comments.commentable_type = 'Proposal' and comments.commentable_id in (?))}, + Debate.public_for_api.pluck(:id), + Proposal.public_for_api.pluck(:id)) + end scope :sort_by_most_voted, -> { order(confidence_score: :desc, created_at: :desc) } scope :sort_descendants_by_most_voted, -> { order(confidence_score: :desc, created_at: :asc) } diff --git a/app/models/concerns/graphqlable.rb b/app/models/concerns/graphqlable.rb new file mode 100644 index 000000000..651a285b9 --- /dev/null +++ b/app/models/concerns/graphqlable.rb @@ -0,0 +1,36 @@ +module Graphqlable + extend ActiveSupport::Concern + + class_methods do + + def graphql_field_name + self.name.gsub('::', '_').underscore.to_sym + end + + def graphql_field_description + "Find one #{self.model_name.human} by ID" + end + + def graphql_pluralized_field_name + self.name.gsub('::', '_').underscore.pluralize.to_sym + end + + def graphql_pluralized_field_description + "Find all #{self.model_name.human.pluralize}" + end + + def graphql_type_name + self.name.gsub('::', '_') + end + + def graphql_type_description + "#{self.model_name.human}" + end + + end + + def public_created_at + self.created_at.change(min: 0) + end + +end diff --git a/app/models/concerns/has_public_author.rb b/app/models/concerns/has_public_author.rb new file mode 100644 index 000000000..a1af255c5 --- /dev/null +++ b/app/models/concerns/has_public_author.rb @@ -0,0 +1,5 @@ +module HasPublicAuthor + def public_author + self.author.public_activity? ? self.author : nil + end +end diff --git a/app/models/concerns/measurable.rb b/app/models/concerns/measurable.rb index 44b7ddabb..5ac2f2a14 100644 --- a/app/models/concerns/measurable.rb +++ b/app/models/concerns/measurable.rb @@ -4,11 +4,11 @@ module Measurable class_methods do def title_max_length - @@title_max_length ||= self.columns.find { |c| c.name == 'title' }.limit || 80 + @@title_max_length ||= (self.columns.find { |c| c.name == 'title' }.limit rescue nil) || 80 end def responsible_name_max_length - @@responsible_name_max_length ||= self.columns.find { |c| c.name == 'responsible_name' }.limit || 60 + @@responsible_name_max_length ||= (self.columns.find { |c| c.name == 'responsible_name' }.limit rescue nil) || 60 end def question_max_length @@ -21,4 +21,4 @@ module Measurable end -end \ No newline at end of file +end diff --git a/app/models/debate.rb b/app/models/debate.rb index de29c5864..95940b3c3 100644 --- a/app/models/debate.rb +++ b/app/models/debate.rb @@ -7,6 +7,8 @@ class Debate < ActiveRecord::Base include Sanitizable include Searchable include Filterable + include HasPublicAuthor + include Graphqlable acts_as_votable acts_as_paranoid column: :hidden_at @@ -37,6 +39,7 @@ class Debate < ActiveRecord::Base scope :sort_by_flags, -> { order(flags_count: :desc, updated_at: :desc) } scope :last_week, -> { where("created_at >= ?", 7.days.ago)} scope :featured, -> { where("featured_at is not null")} + scope :public_for_api, -> { all } # Ahoy setup visitable # Ahoy will automatically assign visit_id on create diff --git a/app/models/geozone.rb b/app/models/geozone.rb index 824879ec6..ad0fe9cd5 100644 --- a/app/models/geozone.rb +++ b/app/models/geozone.rb @@ -1,10 +1,15 @@ class Geozone < ActiveRecord::Base + + include Graphqlable + has_many :proposals has_many :spending_proposals has_many :debates has_many :users validates :name, presence: true + scope :public_for_api, -> { all } + def self.names Geozone.pluck(:name) end diff --git a/app/models/organization.rb b/app/models/organization.rb index 74fd16111..94ef986a1 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -1,4 +1,7 @@ class Organization < ActiveRecord::Base + + include Graphqlable + belongs_to :user, touch: true validates :name, presence: true diff --git a/app/models/proposal.rb b/app/models/proposal.rb index 0abde3584..f37dff3d5 100644 --- a/app/models/proposal.rb +++ b/app/models/proposal.rb @@ -6,6 +6,8 @@ class Proposal < ActiveRecord::Base include Sanitizable include Searchable include Filterable + include HasPublicAuthor + include Graphqlable acts_as_votable acts_as_paranoid column: :hidden_at @@ -51,6 +53,7 @@ class Proposal < ActiveRecord::Base scope :retired, -> { where.not(retired_at: nil) } scope :not_retired, -> { where(retired_at: nil) } scope :successful, -> { where("cached_votes_up >= ?", Proposal.votes_needed_for_success) } + scope :public_for_api, -> { all } def to_param "#{id}-#{title}".parameterize diff --git a/app/models/proposal_notification.rb b/app/models/proposal_notification.rb index 60912d887..9f36679a4 100644 --- a/app/models/proposal_notification.rb +++ b/app/models/proposal_notification.rb @@ -1,4 +1,7 @@ class ProposalNotification < ActiveRecord::Base + + include Graphqlable + belongs_to :author, class_name: 'User', foreign_key: 'author_id' belongs_to :proposal @@ -7,6 +10,8 @@ class ProposalNotification < ActiveRecord::Base validates :proposal, presence: true validate :minimum_interval + scope :public_for_api, -> { where(proposal_id: Proposal.public_for_api.pluck(:id)) } + def minimum_interval return true if proposal.try(:notifications).blank? if proposal.notifications.last.created_at > (Time.current - Setting[:proposal_notification_minimum_interval_in_days].to_i.days).to_datetime diff --git a/app/models/user.rb b/app/models/user.rb index a9318aed0..95b915b02 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -10,6 +10,8 @@ class User < ActiveRecord::Base acts_as_paranoid column: :hidden_at include ActsAsParanoidAliases + include Graphqlable + has_one :administrator has_one :moderator has_one :valuator @@ -61,6 +63,7 @@ class User < ActiveRecord::Base scope :email_digest, -> { where(email_digest: true) } scope :active, -> { where(erased_at: nil) } scope :erased, -> { where.not(erased_at: nil) } + scope :public_for_api, -> { all } before_validation :clean_document_number @@ -288,6 +291,18 @@ class User < ActiveRecord::Base end delegate :can?, :cannot?, to: :ability + def public_proposals + public_activity? ? proposals : User.none + end + + def public_debates + public_activity? ? debates : User.none + end + + def public_comments + public_activity? ? comments : User.none + end + # overwritting of Devise method to allow login using email OR username def self.find_for_database_authentication(warden_conditions) conditions = warden_conditions.dup diff --git a/app/models/vote.rb b/app/models/vote.rb index 47dd3f007..14b11a68d 100644 --- a/app/models/vote.rb +++ b/app/models/vote.rb @@ -1,2 +1,14 @@ class Vote < ActsAsVotable::Vote -end \ No newline at end of file + + include Graphqlable + + scope :public_for_api, -> do + where(%{(votes.votable_type = 'Debate' and votes.votable_id in (?)) or + (votes.votable_type = 'Proposal' and votes.votable_id in (?)) or + (votes.votable_type = 'Comment' and votes.votable_id in (?))}, + Debate.public_for_api.pluck(:id), + Proposal.public_for_api.pluck(:id), + Comment.public_for_api.pluck(:id)) + end + +end diff --git a/config/api.yml b/config/api.yml new file mode 100644 index 000000000..0c668eac2 --- /dev/null +++ b/config/api.yml @@ -0,0 +1,89 @@ +User: + fields: + id: integer + username: string + public_debates: [Debate] + public_proposals: [Proposal] + public_comments: [Comment] +# organization: Organization +Debate: + fields: + id: integer + title: string + description: string + public_created_at: string + cached_votes_total: integer + cached_votes_up: integer + cached_votes_down: integer + comments_count: integer + hot_score: integer + confidence_score: integer + comments: [Comment] + public_author: User + votes_for: [Vote] + tags: ["ActsAsTaggableOn::Tag"] +Proposal: + fields: + id: integer + title: string + description: string + external_url: string + cached_votes_up: integer + comments_count: integer + hot_score: integer + confidence_score: integer + public_created_at: string + summary: string + video_url: string + geozone_id: integer + retired_at: string + retired_reason: string + retired_explanation: string + geozone: Geozone + comments: [Comment] + proposal_notifications: [ProposalNotification] + public_author: User + votes_for: [Vote] + tags: ["ActsAsTaggableOn::Tag"] +Comment: + fields: + id: integer + commentable_id: integer + commentable_type: string + body: string + public_created_at: string + cached_votes_total: integer + cached_votes_up: integer + cached_votes_down: integer + ancestry: string + confidence_score: integer + public_author: User + votes_for: [Vote] +Geozone: + fields: + id: integer + name: string +ProposalNotification: + fields: + title: string + body: string + proposal_id: integer + public_created_at: string + proposal: Proposal +ActsAsTaggableOn::Tag: + fields: + id: integer + name: string + taggings_count: integer + kind: string +Vote: + fields: + votable_id: integer + votable_type: string + public_created_at: string + vote_flag: boolean +# Organization: +# fields: +# id: integer +# user_id: integer +# name: string diff --git a/config/initializers/acts_as_taggable_on.rb b/config/initializers/acts_as_taggable_on.rb index 847a8c993..e570e08de 100644 --- a/config/initializers/acts_as_taggable_on.rb +++ b/config/initializers/acts_as_taggable_on.rb @@ -5,6 +5,15 @@ module ActsAsTaggableOn after_create :increment_tag_custom_counter after_destroy :touch_taggable, :decrement_tag_custom_counter + scope :public_for_api, -> do + where(%{taggings.tag_id in (?) and + (taggings.taggable_type = 'Debate' and taggings.taggable_id in (?)) or + (taggings.taggable_type = 'Proposal' and taggings.taggable_id in (?))}, + Tag.where('kind IS NULL or kind = ?', 'category').pluck(:id), + Debate.public_for_api.pluck(:id), + Proposal.public_for_api.pluck(:id)) + end + def touch_taggable taggable.touch if taggable.present? end @@ -20,6 +29,14 @@ module ActsAsTaggableOn Tag.class_eval do + include Graphqlable + + scope :public_for_api, -> do + where('(tags.kind IS NULL or tags.kind = ?) and tags.id in (?)', + 'category', + Tagging.public_for_api.pluck('DISTINCT taggings.tag_id')) + end + def increment_custom_counter_for(taggable_type) Tag.increment_counter(custom_counter_field_name_for(taggable_type), id) end @@ -42,10 +59,22 @@ module ActsAsTaggableOn ActsAsTaggableOn::Tag.where('taggings.taggable_type' => 'SpendingProposal').includes(:taggings).order(:name).uniq end + def self.graphql_field_name + :tag + end + + def self.graphql_pluralized_field_name + :tags + end + + def self.graphql_type_name + 'Tag' + end + private def custom_counter_field_name_for(taggable_type) "#{taggable_type.underscore.pluralize}_count" end end -end \ No newline at end of file +end diff --git a/config/initializers/graphql.rb b/config/initializers/graphql.rb new file mode 100644 index 000000000..2731e1887 --- /dev/null +++ b/config/initializers/graphql.rb @@ -0,0 +1,4 @@ +if ActiveRecord::Base.connection.tables.any? + api_config = YAML.load_file('./config/api.yml') + API_TYPE_DEFINITIONS = GraphQL::ApiTypesCreator::parse_api_config_file(api_config) +end diff --git a/config/routes.rb b/config/routes.rb index 05d58ffab..a8bb7afc4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -400,8 +400,13 @@ Rails.application.routes.draw do root to: "dashboard#index" end + # GraphQL + get '/graphql', to: 'graphql#query' + post '/graphql', to: 'graphql#query' + if Rails.env.development? mount LetterOpenerWeb::Engine, at: "/letter_opener" + mount GraphiQL::Rails::Engine, at: '/graphiql', graphql_path: '/graphql' end mount Tolk::Engine => '/translate', :as => 'tolk' diff --git a/db/dev_seeds.rb b/db/dev_seeds.rb index 6e5c1cbed..99605b392 100644 --- a/db/dev_seeds.rb +++ b/db/dev_seeds.rb @@ -59,7 +59,17 @@ print "Creating Users" def create_user(email, username = Faker::Name.name) pwd = '12345678' - User.create!(username: username, email: email, password: pwd, password_confirmation: pwd, confirmed_at: Time.current, terms_of_service: "1") + User.create!( + username: username, + email: email, + password: pwd, + password_confirmation: pwd, + confirmed_at: Time.current, + terms_of_service: "1", + gender: ['Male', 'Female'].sample, + date_of_birth: rand((Time.current - 80.years) .. (Time.current - 16.years)), + public_activity: (rand(1..100) > 30) + ) end admin = create_user('admin@consul.dev', 'admin') @@ -103,11 +113,11 @@ end official.update(official_level: i, official_position: "Official position #{i}") end -(1..40).each do |i| +(1..100).each do |i| user = create_user("user#{i}@consul.dev") level = [1, 2, 3].sample if level >= 2 - user.update(residence_verified_at: Time.current, confirmed_phone: Faker::PhoneNumber.phone_number, document_number: Faker::Number.number(10), document_type: "1") + user.update(residence_verified_at: Time.current, confirmed_phone: Faker::PhoneNumber.phone_number, document_number: Faker::Number.number(10), document_type: "1", geozone: Geozone.reorder("RANDOM()").first) end if level == 3 user.update(verified_at: Time.current, document_number: Faker::Number.number(10)) @@ -285,7 +295,7 @@ puts " ✅" print "Voting Debates, Proposals & Comments" 100.times do - voter = not_org_users.reorder("RANDOM()").first + voter = not_org_users.level_two_or_three_verified.reorder("RANDOM()").first vote = [true, false].sample debate = Debate.reorder("RANDOM()").first debate.vote_by(voter: voter, vote: vote) @@ -299,7 +309,7 @@ end end 100.times do - voter = User.level_two_or_three_verified.reorder("RANDOM()").first + voter = not_org_users.level_two_or_three_verified.reorder("RANDOM()").first proposal = Proposal.reorder("RANDOM()").first proposal.vote_by(voter: voter, vote: true) end @@ -487,6 +497,16 @@ Proposal.last(3).each do |proposal| created_at: rand((Time.current - 1.week)..Time.current)) end +puts " ✅" +puts "Creating proposal notifications" + +100.times do |i| + ProposalNotification.create!(title: "Proposal notification title #{i}", + body: "Proposal notification body #{i}", + author: User.reorder("RANDOM()").first, + proposal: Proposal.reorder("RANDOM()").first) +end + puts " ✅" print "Creating polls" diff --git a/db/schema.rb b/db/schema.rb index 1062b3f91..8fae3e8dd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -232,10 +232,10 @@ ActiveRecord::Schema.define(version: 20170613203256) do t.string "visit_id" t.datetime "hidden_at" t.integer "flags_count", default: 0 + t.datetime "ignored_flag_at" t.integer "cached_votes_total", default: 0 t.integer "cached_votes_up", default: 0 t.integer "cached_votes_down", default: 0 - t.datetime "ignored_flag_at" t.integer "comments_count", default: 0 t.datetime "confirmed_hide_at" t.integer "cached_anonymous_votes_total", default: 0 @@ -254,7 +254,6 @@ ActiveRecord::Schema.define(version: 20170613203256) do add_index "debates", ["cached_votes_total"], name: "index_debates_on_cached_votes_total", using: :btree add_index "debates", ["cached_votes_up"], name: "index_debates_on_cached_votes_up", using: :btree add_index "debates", ["confidence_score"], name: "index_debates_on_confidence_score", using: :btree - add_index "debates", ["description"], name: "index_debates_on_description", using: :btree add_index "debates", ["geozone_id"], name: "index_debates_on_geozone_id", using: :btree add_index "debates", ["hidden_at"], name: "index_debates_on_hidden_at", using: :btree add_index "debates", ["hot_score"], name: "index_debates_on_hot_score", using: :btree @@ -718,7 +717,6 @@ ActiveRecord::Schema.define(version: 20170613203256) do add_index "proposals", ["author_id"], name: "index_proposals_on_author_id", using: :btree add_index "proposals", ["cached_votes_up"], name: "index_proposals_on_cached_votes_up", using: :btree add_index "proposals", ["confidence_score"], name: "index_proposals_on_confidence_score", using: :btree - add_index "proposals", ["description"], name: "index_proposals_on_description", using: :btree add_index "proposals", ["geozone_id"], name: "index_proposals_on_geozone_id", using: :btree add_index "proposals", ["hidden_at"], name: "index_proposals_on_hidden_at", using: :btree add_index "proposals", ["hot_score"], name: "index_proposals_on_hot_score", using: :btree @@ -926,7 +924,7 @@ ActiveRecord::Schema.define(version: 20170613203256) do t.boolean "email_digest", default: true t.boolean "email_on_direct_message", default: true t.boolean "official_position_badge", default: false - t.datetime "password_changed_at", default: '2016-11-23 10:59:20', null: false + t.datetime "password_changed_at", default: '2016-12-21 17:55:08', null: false t.boolean "created_from_signature", default: false t.integer "failed_email_digests_count", default: 0 t.text "former_users_data_log", default: "" diff --git a/lib/graph_ql/api_types_creator.rb b/lib/graph_ql/api_types_creator.rb new file mode 100644 index 000000000..f8f78355e --- /dev/null +++ b/lib/graph_ql/api_types_creator.rb @@ -0,0 +1,84 @@ +require 'graphql' + +module GraphQL + class ApiTypesCreator + SCALAR_TYPES = { + integer: GraphQL::INT_TYPE, + boolean: GraphQL::BOOLEAN_TYPE, + float: GraphQL::FLOAT_TYPE, + double: GraphQL::FLOAT_TYPE, + string: GraphQL::STRING_TYPE + } + + def self.create(api_types_definitions) + created_types = {} + api_types_definitions.each do |model, info| + create_type(model, info[:fields], created_types) + end + created_types + end + + def self.type_kind(type) + if SCALAR_TYPES[type] + :scalar + elsif type.class == Class + :singular_association + elsif type.class == Array + :multiple_association + end + end + + def self.create_type(model, fields, created_types) + + created_types[model] = GraphQL::ObjectType.define do + + name model.graphql_type_name + description model.graphql_type_description + + # Make a field for each column, association or method + fields.each do |field_name, field_type| + case ApiTypesCreator.type_kind(field_type) + when :scalar + field(field_name, SCALAR_TYPES[field_type], model.human_attribute_name(field_name)) + when :singular_association + field(field_name, -> { created_types[field_type] }) do + resolve -> (object, arguments, context) do + association_target = object.send(field_name) + association_target.present? ? field_type.public_for_api.find_by(id: association_target.id) : nil + end + end + when :multiple_association + field_type = field_type.first + connection(field_name, -> { created_types[field_type].connection_type }, max_page_size: 50, complexity: 1000) do + resolve -> (object, arguments, context) { object.send(field_name).public_for_api } + end + end + end + + end + end + + def self.parse_api_config_file(file) + api_type_definitions = {} + + file.each do |api_type_model, api_type_info| + model = api_type_model.constantize + fields = {} + + api_type_info['fields'].each do |field_name, field_type| + if field_type.is_a?(Array) # paginated association + fields[field_name.to_sym] = [field_type.first.constantize] + elsif SCALAR_TYPES[field_type.to_sym] + fields[field_name.to_sym] = field_type.to_sym + else # simple association + fields[field_name.to_sym] = field_type.constantize + end + end + + api_type_definitions[model] = { fields: fields } + end + + api_type_definitions + end + end +end diff --git a/lib/graph_ql/query_type_creator.rb b/lib/graph_ql/query_type_creator.rb new file mode 100644 index 000000000..71beb8033 --- /dev/null +++ b/lib/graph_ql/query_type_creator.rb @@ -0,0 +1,31 @@ +require 'graphql' + +module GraphQL + class QueryTypeCreator + + def self.create(api_types) + GraphQL::ObjectType.define do + name 'QueryType' + description 'The root query for the schema' + + api_types.each do |model, created_type| + if created_type.fields['id'] + field model.graphql_field_name do + type created_type + description model.graphql_field_description + argument :id, !types.ID + resolve -> (object, arguments, context) { model.public_for_api.find_by(id: arguments['id'])} + end + end + + connection(model.graphql_pluralized_field_name, created_type.connection_type, max_page_size: 50, complexity: 1000) do + description model.graphql_pluralized_field_description + resolve -> (object, arguments, context) { model.public_for_api } + end + + end + end + end + + end +end diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb new file mode 100644 index 000000000..1b8c64793 --- /dev/null +++ b/spec/controllers/graphql_controller_spec.rb @@ -0,0 +1,91 @@ +require 'rails_helper' + +# Useful resource: http://graphql.org/learn/serving-over-http/ + +def parser_error_raised?(response) + data_is_empty = response['data'].nil? + error_is_present = (JSON.parse(response.body)['errors'].first['message'] =~ /^Parse error on/) + data_is_empty && error_is_present +end + +describe GraphqlController, type: :request do + let(:proposal) { create(:proposal) } + + describe "handles GET request" do + specify "with query string inside query params" do + get '/graphql', query: "{ proposal(id: #{proposal.id}) { title } }" + + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)['data']['proposal']['title']).to eq(proposal.title) + end + + specify "with malformed query string" do + get '/graphql', query: 'Malformed query string' + + expect(response).to have_http_status(:ok) + expect(parser_error_raised?(response)).to be_truthy + end + + specify "without query string" do + get '/graphql' + + expect(response).to have_http_status(:bad_request) + expect(JSON.parse(response.body)['message']).to eq('Query string not present') + end + end + + describe "handles POST request" do + let(:json_headers) { { "CONTENT_TYPE" => "application/json" } } + + specify "with json-encoded query string inside body" do + post '/graphql', { query: "{ proposal(id: #{proposal.id}) { title } }" }.to_json, json_headers + + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)['data']['proposal']['title']).to eq(proposal.title) + end + + specify "with raw query string inside body" do + graphql_headers = { "CONTENT_TYPE" => "application/graphql" } + post '/graphql', "{ proposal(id: #{proposal.id}) { title } }", graphql_headers + + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)['data']['proposal']['title']).to eq(proposal.title) + end + + specify "with malformed query string" do + post '/graphql', { query: "Malformed query string" }.to_json, json_headers + + expect(response).to have_http_status(:ok) + expect(parser_error_raised?(response)).to be_truthy + end + + it "without query string" do + post '/graphql', json_headers + + expect(response).to have_http_status(:bad_request) + expect(JSON.parse(response.body)['message']).to eq('Query string not present') + end + end + + describe "correctly parses query variables" do + let(:query_string) { "{ proposal(id: #{proposal.id}) { title } }" } + + specify "when absent" do + get '/graphql', { query: query_string } + + expect(response).to have_http_status(:ok) + end + + specify "when specified as the 'null' string" do + get '/graphql', { query: query_string, variables: 'null' } + + expect(response).to have_http_status(:ok) + end + + specify "when specified as an empty string" do + get '/graphql', { query: query_string, variables: '' } + + expect(response).to have_http_status(:ok) + end + end +end diff --git a/spec/factories.rb b/spec/factories.rb index c63792dce..7527a0ba1 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -6,8 +6,9 @@ FactoryGirl.define do sequence(:email) { |n| "manuela#{n}@consul.dev" } password 'judgmentday' - terms_of_service '1' + terms_of_service '1' confirmed_at { Time.current } + public_activity true trait :incomplete_verification do after :create do |user| diff --git a/spec/lib/acts_as_taggable_on_spec.rb b/spec/lib/acts_as_taggable_on_spec.rb index dbf76d52b..d261bbad2 100644 --- a/spec/lib/acts_as_taggable_on_spec.rb +++ b/spec/lib/acts_as_taggable_on_spec.rb @@ -64,6 +64,75 @@ describe 'ActsAsTaggableOn' do expect(tag.proposals_count).to eq(1) end end + + describe "public_for_api scope" do + + it "returns tags whose kind is NULL and have at least one tagging whose taggable is not hidden" do + tag = create(:tag, kind: nil) + proposal = create(:proposal) + proposal.tag_list.add(tag) + proposal.save + + expect(ActsAsTaggableOn::Tag.public_for_api).to include(tag) + end + + it "returns tags whose kind is 'category' and have at least one tagging whose taggable is not hidden" do + tag = create(:tag, kind: 'category') + proposal = create(:proposal) + proposal.tag_list.add(tag) + proposal.save + + expect(ActsAsTaggableOn::Tag.public_for_api).to include(tag) + end + + it "blocks other kinds of tags" do + tag = create(:tag, kind: 'foo') + proposal = create(:proposal) + proposal.tag_list.add(tag) + proposal.save + + expect(ActsAsTaggableOn::Tag.public_for_api).not_to include(tag) + end + + it "blocks tags that don't have at least one tagged element" do + tag = create(:tag) + + expect(ActsAsTaggableOn::Tag.public_for_api).to_not include(tag) + end + + it 'only permits tags on proposals or debates' do + tag_1 = create(:tag) + tag_2 = create(:tag) + tag_3 = create(:tag) + + proposal = create(:proposal) + spending_proposal = create(:spending_proposal) + debate = create(:debate) + + proposal.tag_list.add(tag_1) + spending_proposal.tag_list.add(tag_2) + debate.tag_list.add(tag_3) + + proposal.save + spending_proposal.save + debate.save + + expect(ActsAsTaggableOn::Tag.public_for_api).to match_array([tag_1, tag_3]) + end + + it 'blocks tags after its taggings became hidden' do + tag = create(:tag) + proposal = create(:proposal) + proposal.tag_list.add(tag) + proposal.save + + expect(ActsAsTaggableOn::Tag.public_for_api).to include(tag) + + proposal.delete + + expect(ActsAsTaggableOn::Tag.public_for_api).to be_empty + end + end end end diff --git a/spec/lib/graph_ql/api_types_creator_spec.rb b/spec/lib/graph_ql/api_types_creator_spec.rb new file mode 100644 index 000000000..637c7f1fe --- /dev/null +++ b/spec/lib/graph_ql/api_types_creator_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +describe GraphQL::ApiTypesCreator do + let(:created_types) { {} } + + describe "::create_type" do + it "creates fields for Int attributes" do + debate_type = GraphQL::ApiTypesCreator.create_type(Debate, { id: :integer }, created_types) + created_field = debate_type.fields['id'] + + expect(created_field).to be_a(GraphQL::Field) + expect(created_field.type).to be_a(GraphQL::ScalarType) + expect(created_field.type.name).to eq('Int') + end + + it "creates fields for String attributes" do + debate_type = GraphQL::ApiTypesCreator.create_type(Debate, { title: :string }, created_types) + created_field = debate_type.fields['title'] + + expect(created_field).to be_a(GraphQL::Field) + expect(created_field.type).to be_a(GraphQL::ScalarType) + expect(created_field.type.name).to eq('String') + end + + it "creates connections for :belongs_to associations" do + user_type = GraphQL::ApiTypesCreator.create_type(User, { id: :integer }, created_types) + debate_type = GraphQL::ApiTypesCreator.create_type(Debate, { author: User }, created_types) + + connection = debate_type.fields['author'] + + expect(connection).to be_a(GraphQL::Field) + expect(connection.type).to eq(user_type) + expect(connection.name).to eq('author') + end + + it "creates connections for :has_one associations" do + user_type = GraphQL::ApiTypesCreator.create_type(User, { organization: Organization }, created_types) + organization_type = GraphQL::ApiTypesCreator.create_type(Organization, { id: :integer }, created_types) + + connection = user_type.fields['organization'] + + expect(connection).to be_a(GraphQL::Field) + expect(connection.type).to eq(organization_type) + expect(connection.name).to eq('organization') + end + + it "creates connections for :has_many associations" do + comment_type = GraphQL::ApiTypesCreator.create_type(Comment, { id: :integer }, created_types) + debate_type = GraphQL::ApiTypesCreator.create_type(Debate, { comments: [Comment] }, created_types) + + connection = debate_type.fields['comments'] + + expect(connection).to be_a(GraphQL::Field) + expect(connection.type).to eq(comment_type.connection_type) + expect(connection.name).to eq('comments') + end + end +end diff --git a/spec/lib/graph_ql/query_type_creator_spec.rb b/spec/lib/graph_ql/query_type_creator_spec.rb new file mode 100644 index 000000000..6d3fe7a2a --- /dev/null +++ b/spec/lib/graph_ql/query_type_creator_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +describe GraphQL::QueryTypeCreator do + let(:api_type_definitions) do + { + ProposalNotification => { fields: { title: :string } }, + Proposal => { fields: { id: :integer, title: :string } } + } + end + let(:api_types) { GraphQL::ApiTypesCreator.create(api_type_definitions) } + + describe "::create" do + let(:query_type) { GraphQL::QueryTypeCreator.create(api_types) } + + it 'creates a QueryType with fields to retrieve single objects whose model fields included an ID' do + field = query_type.fields['proposal'] + + expect(field).to be_a(GraphQL::Field) + expect(field.type).to eq(api_types[Proposal]) + expect(field.name).to eq('proposal') + end + + it 'creates a QueryType without fields to retrieve single objects whose model fields did not include an ID' do + expect(query_type.fields['proposal_notification']).to be_nil + end + + it "creates a QueryType with connections to retrieve collections of objects" do + connection = query_type.fields['proposals'] + + expect(connection).to be_a(GraphQL::Field) + expect(connection.type).to eq(api_types[Proposal].connection_type) + expect(connection.name).to eq('proposals') + end + end +end diff --git a/spec/lib/graphql_spec.rb b/spec/lib/graphql_spec.rb new file mode 100644 index 000000000..725cc109a --- /dev/null +++ b/spec/lib/graphql_spec.rb @@ -0,0 +1,674 @@ +require 'rails_helper' + +api_types = GraphQL::ApiTypesCreator.create(API_TYPE_DEFINITIONS) +query_type = GraphQL::QueryTypeCreator.create(api_types) +ConsulSchema = GraphQL::Schema.define do + query query_type + max_depth 12 +end + +def execute(query_string, context = {}, variables = {}) + ConsulSchema.execute(query_string, context: context, variables: variables) +end + +def dig(response, path) + response.dig(*path.split('.')) +end + +def hidden_field?(response, field_name) + data_is_empty = response['data'].nil? + error_is_present = ((response['errors'].first['message'] =~ /Field '#{field_name}' doesn't exist on type '[[:alnum:]]*'/) == 0) + data_is_empty && error_is_present +end + +def extract_fields(response, collection_name, field_chain) + fields = field_chain.split('.') + dig(response, "data.#{collection_name}.edges").collect do |node| + begin + if fields.size > 1 + node['node'][fields.first][fields.second] + else + node['node'][fields.first] + end + rescue NoMethodError + end + end.compact +end + +describe 'ConsulSchema' do + let(:user) { create(:user) } + let(:proposal) { create(:proposal, author: user) } + + it 'returns fields of Int type' do + response = execute("{ proposal(id: #{proposal.id}) { id } }") + expect(dig(response, 'data.proposal.id')).to eq(proposal.id) + end + + it 'returns fields of String type' do + response = execute("{ proposal(id: #{proposal.id}) { title } }") + expect(dig(response, 'data.proposal.title')).to eq(proposal.title) + end + + xit 'returns has_one associations' do + organization = create(:organization) + response = execute("{ user(id: #{organization.user_id}) { organization { name } } }") + expect(dig(response, 'data.user.organization.name')).to eq(organization.name) + end + + it 'returns belongs_to associations' do + response = execute("{ proposal(id: #{proposal.id}) { public_author { username } } }") + expect(dig(response, 'data.proposal.public_author.username')).to eq(proposal.public_author.username) + end + + it 'returns has_many associations' do + comments_author = create(:user) + comment_1 = create(:comment, author: comments_author, commentable: proposal) + comment_2 = create(:comment, author: comments_author, commentable: proposal) + + response = execute("{ proposal(id: #{proposal.id}) { comments { edges { node { body } } } } }") + comments = dig(response, 'data.proposal.comments.edges').collect { |edge| edge['node'] } + comment_bodies = comments.collect { |comment| comment['body'] } + + expect(comment_bodies).to match_array([comment_1.body, comment_2.body]) + end + + xit 'executes deeply nested queries' do + org_user = create(:user) + organization = create(:organization, user: org_user) + org_proposal = create(:proposal, author: org_user) + response = execute("{ proposal(id: #{org_proposal.id}) { public_author { organization { name } } } }") + + expect(dig(response, 'data.proposal.public_author.organization.name')).to eq(organization.name) + end + + it 'hides confidential fields of Int type' do + response = execute("{ user(id: #{user.id}) { failed_census_calls_count } }") + expect(hidden_field?(response, 'failed_census_calls_count')).to be_truthy + end + + it 'hides confidential fields of String type' do + response = execute("{ user(id: #{user.id}) { encrypted_password } }") + expect(hidden_field?(response, 'encrypted_password')).to be_truthy + end + + xit 'hides confidential has_one associations' do + user.administrator = create(:administrator) + response = execute("{ user(id: #{user.id}) { administrator { id } } }") + expect(hidden_field?(response, 'administrator')).to be_truthy + end + + it 'hides confidential belongs_to associations' do + create(:failed_census_call, user: user) + response = execute("{ user(id: #{user.id}) { failed_census_calls { id } } }") + expect(hidden_field?(response, 'failed_census_calls')).to be_truthy + end + + it 'hides confidential has_many associations' do + create(:direct_message, sender: user) + response = execute("{ user(id: #{user.id}) { direct_messages_sent { id } } }") + expect(hidden_field?(response, 'direct_messages_sent')).to be_truthy + end + + it 'hides confidential fields inside deeply nested queries' do + response = execute("{ proposals(first: 1) { edges { node { public_author { encrypted_password } } } } }") + expect(hidden_field?(response, 'encrypted_password')).to be_truthy + end + + describe 'Users' do + let(:user) { create(:user, public_activity: false) } + + it 'does not link debates if activity is not public' do + create(:debate, author: user) + + response = execute("{ user(id: #{user.id}) { public_debates { edges { node { title } } } } }") + received_debates = dig(response, 'data.user.public_debates.edges') + + expect(received_debates).to eq [] + end + + it 'does not link proposals if activity is not public' do + create(:proposal, author: user) + + response = execute("{ user(id: #{user.id}) { public_proposals { edges { node { title } } } } }") + received_proposals = dig(response, 'data.user.public_proposals.edges') + + expect(received_proposals).to eq [] + end + + it 'does not link comments if activity is not public' do + create(:comment, author: user) + + response = execute("{ user(id: #{user.id}) { public_comments { edges { node { body } } } } }") + received_comments = dig(response, 'data.user.public_comments.edges') + + expect(received_comments).to eq [] + end + + end + + describe 'Proposals' do + it 'does not include hidden proposals' do + visible_proposal = create(:proposal) + hidden_proposal = create(:proposal, :hidden) + + response = execute('{ proposals { edges { node { title } } } }') + received_titles = extract_fields(response, 'proposals', 'title') + + expect(received_titles).to match_array [visible_proposal.title] + end + + xit 'only returns proposals of the Human Rights proceeding' do + proposal = create(:proposal) + human_rights_proposal = create(:proposal, proceeding: 'Derechos Humanos', sub_proceeding: 'Right to have a job') + other_proceeding_proposal = create(:proposal) + other_proceeding_proposal.update_attribute(:proceeding, 'Another proceeding') + + response = execute('{ proposals { edges { node { title } } } }') + received_titles = extract_fields(response, 'proposals', 'title') + + expect(received_titles).to match_array [proposal.title, human_rights_proposal.title] + end + + it 'includes proposals of authors even if public activity is set to false' do + visible_author = create(:user, public_activity: true) + hidden_author = create(:user, public_activity: false) + + visible_proposal = create(:proposal, author: visible_author) + hidden_proposal = create(:proposal, author: hidden_author) + + response = execute('{ proposals { edges { node { title } } } }') + received_titles = extract_fields(response, 'proposals', 'title') + + expect(received_titles).to match_array [visible_proposal.title, hidden_proposal.title] + end + + it 'does not link author if public activity is set to false' do + visible_author = create(:user, public_activity: true) + hidden_author = create(:user, public_activity: false) + + visible_proposal = create(:proposal, author: visible_author) + hidden_proposal = create(:proposal, author: hidden_author) + + response = execute('{ proposals { edges { node { public_author { username } } } } }') + received_authors = extract_fields(response, 'proposals', 'public_author.username') + + expect(received_authors).to match_array [visible_author.username] + end + + it 'only returns date and hour for created_at' do + created_at = Time.zone.parse("2017-12-31 9:30:15") + create(:proposal, created_at: created_at) + + response = execute('{ proposals { edges { node { public_created_at } } } }') + received_timestamps = extract_fields(response, 'proposals', 'public_created_at') + + expect(Time.zone.parse(received_timestamps.first)).to eq Time.zone.parse("2017-12-31 9:00:00") + end + + it 'only retruns tags with kind nil or category' do + tag = create(:tag, name: 'Parks') + category_tag = create(:tag, name: 'Health', kind: 'category') + admin_tag = create(:tag, name: 'Admin tag', kind: 'admin') + + proposal = create(:proposal, tag_list: 'Parks, Health, Admin tag') + + response = execute("{ proposal(id: #{proposal.id}) { tags { edges { node { name } } } } }") + received_tags = dig(response, 'data.proposal.tags.edges').map { |node| node['node']['name'] } + + expect(received_tags).to match_array ['Parks', 'Health'] + end + + end + + describe 'Debates' do + it 'does not include hidden debates' do + visible_debate = create(:debate) + hidden_debate = create(:debate, :hidden) + + response = execute('{ debates { edges { node { title } } } }') + received_titles = extract_fields(response, 'debates', 'title') + + expect(received_titles).to match_array [visible_debate.title] + end + + it 'includes debates of authors even if public activity is set to false' do + visible_author = create(:user, public_activity: true) + hidden_author = create(:user, public_activity: false) + + visible_debate = create(:debate, author: visible_author) + hidden_debate = create(:debate, author: hidden_author) + + response = execute('{ debates { edges { node { title } } } }') + received_titles = extract_fields(response, 'debates', 'title') + + expect(received_titles).to match_array [visible_debate.title, hidden_debate.title] + end + + it 'does not link author if public activity is set to false' do + visible_author = create(:user, public_activity: true) + hidden_author = create(:user, public_activity: false) + + visible_debate = create(:debate, author: visible_author) + hidden_debate = create(:debate, author: hidden_author) + + response = execute('{ debates { edges { node { public_author { username } } } } }') + received_authors = extract_fields(response, 'debates', 'public_author.username') + + expect(received_authors).to match_array [visible_author.username] + end + + it 'only returns date and hour for created_at' do + created_at = Time.zone.parse("2017-12-31 9:30:15") + create(:debate, created_at: created_at) + + response = execute('{ debates { edges { node { public_created_at } } } }') + received_timestamps = extract_fields(response, 'debates', 'public_created_at') + + expect(Time.zone.parse(received_timestamps.first)).to eq Time.zone.parse("2017-12-31 9:00:00") + end + + it 'only retruns tags with kind nil or category' do + tag = create(:tag, name: 'Parks') + category_tag = create(:tag, name: 'Health', kind: 'category') + admin_tag = create(:tag, name: 'Admin tag', kind: 'admin') + + debate = create(:debate, tag_list: 'Parks, Health, Admin tag') + + response = execute("{ debate(id: #{debate.id}) { tags { edges { node { name } } } } }") + received_tags = dig(response, 'data.debate.tags.edges').map { |node| node['node']['name'] } + + expect(received_tags).to match_array ['Parks', 'Health'] + end + end + + describe 'Comments' do + it 'only returns comments from proposals and debates' do + proposal_comment = create(:comment, commentable: create(:proposal)) + debate_comment = create(:comment, commentable: create(:debate)) + spending_proposal_comment = build(:comment, commentable: create(:spending_proposal)).save(skip_validation: true) + + response = execute('{ comments { edges { node { commentable_type } } } }') + received_commentables = extract_fields(response, 'comments', 'commentable_type') + + expect(received_commentables).to match_array ['Proposal', 'Debate'] + end + + it 'displays comments of authors even if public activity is set to false' do + visible_author = create(:user, public_activity: true) + hidden_author = create(:user, public_activity: false) + + visible_comment = create(:comment, user: visible_author) + hidden_comment = create(:comment, user: hidden_author) + + response = execute('{ comments { edges { node { body } } } }') + received_comments = extract_fields(response, 'comments', 'body') + + expect(received_comments).to match_array [visible_comment.body, hidden_comment.body] + end + + it 'does not link author if public activity is set to false' do + visible_author = create(:user, public_activity: true) + hidden_author = create(:user, public_activity: false) + + visible_comment = create(:comment, author: visible_author) + hidden_comment = create(:comment, author: hidden_author) + + response = execute('{ comments { edges { node { public_author { username } } } } }') + received_authors = extract_fields(response, 'comments', 'public_author.username') + + expect(received_authors).to match_array [visible_author.username] + end + + it 'does not include hidden comments' do + visible_comment = create(:comment) + hidden_comment = create(:comment, hidden_at: Time.now) + + response = execute('{ comments { edges { node { body } } } }') + received_comments = extract_fields(response, 'comments', 'body') + + expect(received_comments).to match_array [visible_comment.body] + end + + it 'does not include comments from hidden proposals' do + visible_proposal = create(:proposal) + hidden_proposal = create(:proposal, hidden_at: Time.now) + + visible_proposal_comment = create(:comment, commentable: visible_proposal) + hidden_proposal_comment = create(:comment, commentable: hidden_proposal) + + response = execute('{ comments { edges { node { body } } } }') + received_comments = extract_fields(response, 'comments', 'body') + + expect(received_comments).to match_array [visible_proposal_comment.body] + end + + it 'does not include comments from hidden debates' do + visible_debate = create(:debate) + hidden_debate = create(:debate, hidden_at: Time.now) + + visible_debate_comment = create(:comment, commentable: visible_debate) + hidden_debate_comment = create(:comment, commentable: hidden_debate) + + response = execute('{ comments { edges { node { body } } } }') + received_comments = extract_fields(response, 'comments', 'body') + + expect(received_comments).to match_array [visible_debate_comment.body] + end + + it 'does not include comments of debates that are not public' do + not_public_debate = create(:debate, :hidden) + not_public_debate_comment = create(:comment, commentable: not_public_debate) + allow(Comment).to receive(:public_for_api).and_return([]) + + response = execute('{ comments { edges { node { body } } } }') + received_comments = extract_fields(response, 'comments', 'body') + + expect(received_comments).to_not include(not_public_debate_comment.body) + end + + it 'does not include comments of proposals that are not public' do + not_public_proposal = create(:proposal) + not_public_proposal_comment = create(:comment, commentable: not_public_proposal) + allow(Comment).to receive(:public_for_api).and_return([]) + + response = execute('{ comments { edges { node { body } } } }') + received_comments = extract_fields(response, 'comments', 'body') + + expect(received_comments).to_not include(not_public_proposal_comment.body) + end + + it 'only returns date and hour for created_at' do + created_at = Time.zone.parse("2017-12-31 9:30:15") + create(:comment, created_at: created_at) + + response = execute('{ comments { edges { node { public_created_at } } } }') + received_timestamps = extract_fields(response, 'comments', 'public_created_at') + + expect(Time.zone.parse(received_timestamps.first)).to eq Time.zone.parse("2017-12-31 9:00:00") + end + end + + describe 'Geozones' do + it 'returns geozones' do + geozone_names = [ create(:geozone), create(:geozone) ].map { |geozone| geozone.name } + + response = execute('{ geozones { edges { node { name } } } }') + received_names = extract_fields(response, 'geozones', 'name') + + expect(received_names).to match_array geozone_names + end + end + + describe 'Proposal notifications' do + + it 'does not include proposal notifications for hidden proposals' do + visible_proposal = create(:proposal) + hidden_proposal = create(:proposal, :hidden) + + visible_proposal_notification = create(:proposal_notification, proposal: visible_proposal) + hidden_proposal_notification = create(:proposal_notification, proposal: hidden_proposal) + + response = execute('{ proposal_notifications { edges { node { title } } } }') + received_notifications = extract_fields(response, 'proposal_notifications', 'title') + + expect(received_notifications).to match_array [visible_proposal_notification.title] + end + + it 'does not include proposal notifications for proposals that are not public' do + not_public_proposal = create(:proposal) + not_public_proposal_notification = create(:proposal_notification, proposal: not_public_proposal) + allow(ProposalNotification).to receive(:public_for_api).and_return([]) + + response = execute('{ proposal_notifications { edges { node { title } } } }') + received_notifications = extract_fields(response, 'proposal_notifications', 'title') + + expect(received_notifications).to_not include(not_public_proposal_notification.title) + end + + it 'only returns date and hour for created_at' do + created_at = Time.zone.parse("2017-12-31 9:30:15") + create(:proposal_notification, created_at: created_at) + + response = execute('{ proposal_notifications { edges { node { public_created_at } } } }') + received_timestamps = extract_fields(response, 'proposal_notifications', 'public_created_at') + + expect(Time.zone.parse(received_timestamps.first)).to eq Time.zone.parse("2017-12-31 9:00:00") + end + + it 'only links proposal if public' do + visible_proposal = create(:proposal) + hidden_proposal = create(:proposal, :hidden) + + visible_proposal_notification = create(:proposal_notification, proposal: visible_proposal) + hidden_proposal_notification = create(:proposal_notification, proposal: hidden_proposal) + + response = execute('{ proposal_notifications { edges { node { proposal { title } } } } }') + received_proposals = extract_fields(response, 'proposal_notifications', 'proposal.title') + + expect(received_proposals).to match_array [visible_proposal.title] + end + + end + + describe 'Tags' do + it 'only display tags with kind nil or category' do + tag = create(:tag, name: 'Parks') + category_tag = create(:tag, name: 'Health', kind: 'category') + admin_tag = create(:tag, name: 'Admin tag', kind: 'admin') + + proposal = create(:proposal, tag_list: 'Parks') + proposal = create(:proposal, tag_list: 'Health') + proposal = create(:proposal, tag_list: 'Admin tag') + + response = execute('{ tags { edges { node { name } } } }') + received_tags = extract_fields(response, 'tags', 'name') + + expect(received_tags).to match_array ['Parks', 'Health'] + end + + xit 'uppercase and lowercase tags work ok together for proposals' do + create(:tag, name: 'Health') + create(:tag, name: 'health') + create(:proposal, tag_list: 'health') + create(:proposal, tag_list: 'Health') + + response = execute('{ tags { edges { node { name } } } }') + received_tags = extract_fields(response, 'tags', 'name') + + expect(received_tags).to match_array ['Health', 'health'] + end + + xit 'uppercase and lowercase tags work ok together for debates' do + create(:tag, name: 'Health') + create(:tag, name: 'health') + create(:debate, tag_list: 'Health') + create(:debate, tag_list: 'health') + + response = execute('{ tags { edges { node { name } } } }') + received_tags = extract_fields(response, 'tags', 'name') + + expect(received_tags).to match_array ['Health', 'health'] + end + + it 'does not display tags for hidden proposals' do + proposal = create(:proposal, tag_list: 'Health') + hidden_proposal = create(:proposal, :hidden, tag_list: 'SPAM') + + response = execute('{ tags { edges { node { name } } } }') + received_tags = extract_fields(response, 'tags', 'name') + + expect(received_tags).to match_array ['Health'] + end + + it 'does not display tags for hidden debates' do + debate = create(:debate, tag_list: 'Health, Transportation') + hidden_debate = create(:debate, :hidden, tag_list: 'SPAM') + + response = execute('{ tags { edges { node { name } } } }') + received_tags = extract_fields(response, 'tags', 'name') + + expect(received_tags).to match_array ['Health', 'Transportation'] + end + + xit "does not display tags for proceeding's proposals" do + valid_proceeding_proposal = create(:proposal, proceeding: 'Derechos Humanos', sub_proceeding: 'Right to a Home', tag_list: 'Health') + invalid_proceeding_proposal = create(:proposal, tag_list: 'Animals') + invalid_proceeding_proposal.update_attribute('proceeding', 'Random') + + response = execute('{ tags { edges { node { name } } } }') + received_tags = extract_fields(response, 'tags', 'name') + + expect(received_tags).to match_array ['Health'] + end + + it 'does not display tags for taggings that are not public' do + proposal = create(:proposal, tag_list: 'Health') + allow(ActsAsTaggableOn::Tag).to receive(:public_for_api).and_return([]) + + response = execute('{ tags { edges { node { name } } } }') + received_tags = extract_fields(response, 'tags', 'name') + + expect(received_tags).to_not include('Health') + end + + end + + describe 'Votes' do + + it 'only returns votes from proposals, debates and comments' do + proposal = create(:proposal) + debate = create(:debate) + comment = create(:comment) + spending_proposal = create(:spending_proposal) + + proposal_vote = create(:vote, votable: proposal) + debate_vote = create(:vote, votable: debate) + comment_vote = create(:vote, votable: comment) + spending_proposal_vote = create(:vote, votable: spending_proposal) + + response = execute('{ votes { edges { node { votable_type } } } }') + received_votables = extract_fields(response, 'votes', 'votable_type') + + expect(received_votables).to match_array ['Proposal', 'Debate', 'Comment'] + end + + it 'does not include votes from hidden debates' do + visible_debate = create(:debate) + hidden_debate = create(:debate, :hidden) + + visible_debate_vote = create(:vote, votable: visible_debate) + hidden_debate_vote = create(:vote, votable: hidden_debate) + + response = execute('{ votes { edges { node { votable_id } } } }') + received_debates = extract_fields(response, 'votes', 'votable_id') + + expect(received_debates).to match_array [visible_debate.id] + end + + it 'does not include votes of hidden proposals' do + visible_proposal = create(:proposal) + hidden_proposal = create(:proposal, hidden_at: Time.now) + + visible_proposal_vote = create(:vote, votable: visible_proposal) + hidden_proposal_vote = create(:vote, votable: hidden_proposal) + + response = execute('{ votes { edges { node { votable_id } } } }') + received_proposals = extract_fields(response, 'votes', 'votable_id') + + expect(received_proposals).to match_array [visible_proposal.id] + end + + it 'does not include votes of hidden comments' do + visible_comment = create(:comment) + hidden_comment = create(:comment, hidden_at: Time.now) + + visible_comment_vote = create(:vote, votable: visible_comment) + hidden_comment_vote = create(:vote, votable: hidden_comment) + + response = execute('{ votes { edges { node { votable_id } } } }') + received_comments = extract_fields(response, 'votes', 'votable_id') + + expect(received_comments).to match_array [visible_comment.id] + end + + it 'does not include votes of comments from a hidden proposal' do + visible_proposal = create(:proposal) + hidden_proposal = create(:proposal, :hidden) + + visible_proposal_comment = create(:comment, commentable: visible_proposal) + hidden_proposal_comment = create(:comment, commentable: hidden_proposal) + + visible_proposal_comment_vote = create(:vote, votable: visible_proposal_comment) + hidden_proposal_comment_vote = create(:vote, votable: hidden_proposal_comment) + + response = execute('{ votes { edges { node { votable_id } } } }') + received_votables = extract_fields(response, 'votes', 'votable_id') + + expect(received_votables).to match_array [visible_proposal_comment.id] + end + + it 'does not include votes of comments from a hidden debate' do + visible_debate = create(:debate) + hidden_debate = create(:debate, :hidden) + + visible_debate_comment = create(:comment, commentable: visible_debate) + hidden_debate_comment = create(:comment, commentable: hidden_debate) + + visible_debate_comment_vote = create(:vote, votable: visible_debate_comment) + hidden_debate_comment_vote = create(:vote, votable: hidden_debate_comment) + + response = execute('{ votes { edges { node { votable_id } } } }') + received_votables = extract_fields(response, 'votes', 'votable_id') + + expect(received_votables).to match_array [visible_debate_comment.id] + end + + it 'does not include votes of debates that are not public' do + not_public_debate = create(:debate) + allow(Vote).to receive(:public_for_api).and_return([]) + + not_public_debate_vote = create(:vote, votable: not_public_debate) + + response = execute('{ votes { edges { node { votable_id } } } }') + received_votables = extract_fields(response, 'votes', 'votable_id') + + expect(received_votables).to_not include(not_public_debate.id) + end + + it 'does not include votes of a hidden proposals' do + not_public_proposal = create(:proposal) + allow(Vote).to receive(:public_for_api).and_return([]) + + not_public_proposal_vote = create(:vote, votable: not_public_proposal) + + response = execute('{ votes { edges { node { votable_id } } } }') + received_votables = extract_fields(response, 'votes', 'votable_id') + + expect(received_votables).to_not include(not_public_proposal.id) + end + + it 'does not include votes of a hidden comments' do + not_public_comment = create(:comment) + allow(Vote).to receive(:public_for_api).and_return([]) + + not_public_comment_vote = create(:vote, votable: not_public_comment) + + response = execute('{ votes { edges { node { votable_id } } } }') + received_votables = extract_fields(response, 'votes', 'votable_id') + + expect(received_votables).to_not include(not_public_comment.id) + end + + it 'only returns date and hour for created_at' do + created_at = Time.zone.parse("2017-12-31 9:30:15") + create(:vote, created_at: created_at) + + response = execute('{ votes { edges { node { public_created_at } } } }') + received_timestamps = extract_fields(response, 'votes', 'public_created_at') + + expect(Time.zone.parse(received_timestamps.first)).to eq Time.zone.parse("2017-12-31 9:00:00") + end + + end + +end diff --git a/spec/models/comment_spec.rb b/spec/models/comment_spec.rb index b899b4e89..a060ef59c 100644 --- a/spec/models/comment_spec.rb +++ b/spec/models/comment_spec.rb @@ -4,6 +4,8 @@ describe Comment do let(:comment) { build(:comment) } + it_behaves_like "has_public_author" + it "is valid" do expect(comment).to be_valid end @@ -132,4 +134,58 @@ describe Comment do end end + describe "public_for_api scope" do + it "returns comments" do + comment = create(:comment) + + expect(Comment.public_for_api).to include(comment) + end + + it "does not return hidden comments" do + hidden_comment = create(:comment, :hidden) + + expect(Comment.public_for_api).not_to include(hidden_comment) + end + + it "returns comments on debates" do + debate = create(:debate) + comment = create(:comment, commentable: debate) + + expect(Comment.public_for_api).to include(comment) + end + + it "does not return comments on hidden debates" do + hidden_debate = create(:debate, :hidden) + comment = create(:comment, commentable: hidden_debate) + + expect(Comment.public_for_api).not_to include(comment) + end + + it "returns comments on proposals" do + proposal = create(:proposal) + comment = create(:comment, commentable: proposal) + + expect(Comment.public_for_api).to include(comment) + end + + it "does not return comments on hidden proposals" do + hidden_proposal = create(:proposal, :hidden) + comment = create(:comment, commentable: hidden_proposal) + + expect(Comment.public_for_api).not_to include(comment) + end + + it 'does not return comments on elements which are not debates or proposals' do + budget_investment = create(:budget_investment) + comment = create(:comment, commentable: budget_investment) + + expect(Comment.public_for_api).not_to include(comment) + end + + it 'does not return comments with no commentable' do + comment = build(:comment, commentable: nil).save!(validate: false) + + expect(Comment.public_for_api).to_not include(comment) + end + end end diff --git a/spec/models/concerns/has_public_author_spec.rb b/spec/models/concerns/has_public_author_spec.rb new file mode 100644 index 000000000..a5a9c2811 --- /dev/null +++ b/spec/models/concerns/has_public_author_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +shared_examples_for 'has_public_author' do + let(:model) { described_class } + + describe 'public_author' do + it "returns author if author's activity is public" do + author = create(:user, public_activity: true) + authored_element = create(model.to_s.underscore.to_sym, author: author) + + expect(authored_element.public_author).to eq(author) + end + + it "returns nil if author's activity is private" do + author = create(:user, public_activity: false) + authored_element = create(model.to_s.underscore.to_sym, author: author) + + expect(authored_element.public_author).to be_nil + end + end +end diff --git a/spec/models/debate_spec.rb b/spec/models/debate_spec.rb index b3a0be53a..612775ee4 100644 --- a/spec/models/debate_spec.rb +++ b/spec/models/debate_spec.rb @@ -4,6 +4,8 @@ require 'rails_helper' describe Debate do let(:debate) { build(:debate) } + it_behaves_like "has_public_author" + it "should be valid" do expect(debate).to be_valid end @@ -700,4 +702,16 @@ describe Debate do end end + describe 'public_for_api scope' do + it 'returns debates' do + debate = create(:debate) + expect(Debate.public_for_api).to include(debate) + end + + it 'does not return hidden debates' do + debate = create(:debate, :hidden) + expect(Debate.public_for_api).to_not include(debate) + end + end + end diff --git a/spec/models/proposal_notification_spec.rb b/spec/models/proposal_notification_spec.rb index f66b254b6..88a98ebee 100644 --- a/spec/models/proposal_notification_spec.rb +++ b/spec/models/proposal_notification_spec.rb @@ -22,6 +22,28 @@ describe ProposalNotification do expect(notification).to_not be_valid end + describe "public_for_api scope" do + it "returns proposal notifications" do + proposal = create(:proposal) + notification = create(:proposal_notification, proposal: proposal) + + expect(ProposalNotification.public_for_api).to include(notification) + end + + it "blocks proposal notifications whose proposal is hidden" do + proposal = create(:proposal, :hidden) + notification = create(:proposal_notification, proposal: proposal) + + expect(ProposalNotification.public_for_api).not_to include(notification) + end + + it "blocks proposal notifications without proposal" do + proposal = build(:proposal_notification, proposal: nil).save!(validate: false) + + expect(ProposalNotification.public_for_api).not_to include(notification) + end + end + describe "minimum interval between notifications" do before(:each) do diff --git a/spec/models/proposal_spec.rb b/spec/models/proposal_spec.rb index 9069e3097..7a87e365f 100644 --- a/spec/models/proposal_spec.rb +++ b/spec/models/proposal_spec.rb @@ -4,6 +4,8 @@ require 'rails_helper' describe Proposal do let(:proposal) { build(:proposal) } + it_behaves_like "has_public_author" + it "should be valid" do expect(proposal).to be_valid end @@ -843,4 +845,16 @@ describe Proposal do end end + describe 'public_for_api scope' do + it 'returns proposals' do + proposal = create(:proposal) + expect(Proposal.public_for_api).to include(proposal) + end + + it 'does not return hidden proposals' do + proposal = create(:proposal, :hidden) + expect(Proposal.public_for_api).to_not include(proposal) + end + end + end diff --git a/spec/models/vote_spec.rb b/spec/models/vote_spec.rb index 1a44e7606..25fd069f2 100644 --- a/spec/models/vote_spec.rb +++ b/spec/models/vote_spec.rb @@ -40,4 +40,77 @@ describe 'Vote' do expect(vote.value).to eq(false) end end + + describe 'public_for_api scope' do + it 'returns votes on debates' do + debate = create(:debate) + vote = create(:vote, votable: debate) + + expect(Vote.public_for_api).to include(vote) + end + + it 'blocks votes on hidden debates' do + debate = create(:debate, :hidden) + vote = create(:vote, votable: debate) + + expect(Vote.public_for_api).not_to include(vote) + end + + it 'returns votes on proposals' do + proposal = create(:proposal) + vote = create(:vote, votable: proposal) + + expect(Vote.public_for_api).to include(vote) + end + + it 'blocks votes on hidden proposals' do + proposal = create(:proposal, :hidden) + vote = create(:vote, votable: proposal) + + expect(Vote.public_for_api).not_to include(vote) + end + + it 'returns votes on comments' do + comment = create(:comment) + vote = create(:vote, votable: comment) + + expect(Vote.public_for_api).to include(vote) + end + + it 'blocks votes on hidden comments' do + comment = create(:comment, :hidden) + vote = create(:vote, votable: comment) + + expect(Vote.public_for_api).not_to include(vote) + end + + it 'blocks votes on comments on hidden proposals' do + hidden_proposal = create(:proposal, :hidden) + comment_on_hidden_proposal = create(:comment, commentable: hidden_proposal) + vote = create(:vote, votable: comment_on_hidden_proposal) + + expect(Vote.public_for_api).to_not include(vote) + end + + it 'blocks votes on comments on hidden debates' do + hidden_debate = create(:debate, :hidden) + comment_on_hidden_debate = create(:comment, commentable: hidden_debate) + vote = create(:vote, votable: comment_on_hidden_debate) + + expect(Vote.public_for_api).to_not include(vote) + end + + it 'blocks any other kind of votes' do + spending_proposal = create(:spending_proposal) + vote = create(:vote, votable: spending_proposal) + + expect(Vote.public_for_api).not_to include(vote) + end + + it 'blocks votes without votable' do + vote = build(:vote, votable: nil).save!(validate: false) + + expect(Vote.public_for_api).not_to include(vote) + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c90a20c39..e9e055bca 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,7 @@ require 'database_cleaner' require 'email_spec' require 'devise' require 'knapsack' +Dir["./spec/models/concerns/*.rb"].each { |f| require f } Dir["./spec/support/**/*.rb"].sort.each { |f| require f } RSpec.configure do |config|