Merge pull request #1653 from consul/amiedes-api-dev-PRs-2

Graphql API
This commit is contained in:
Enrique García
2017-06-15 11:27:58 +02:00
committed by GitHub
35 changed files with 1559 additions and 16 deletions

View File

@@ -71,6 +71,8 @@ gem 'rails-assets-markdown-it', source: 'https://rails-assets.org'
gem 'cocoon' gem 'cocoon'
gem 'graphql', '~> 1.6.3'
group :development, :test do group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console # Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug' gem 'byebug'
@@ -106,6 +108,7 @@ end
group :development do group :development do
# Access an IRB console on exception pages or by using <%= console %> in views # Access an IRB console on exception pages or by using <%= console %> in views
gem 'web-console', '3.3.0' gem 'web-console', '3.3.0'
gem 'graphiql-rails', '~> 1.4.1'
end end
eval_gemfile './Gemfile_custom' eval_gemfile './Gemfile_custom'

View File

@@ -176,8 +176,10 @@ GEM
geocoder (1.4.3) geocoder (1.4.3)
globalid (0.3.7) globalid (0.3.7)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
graphiql-rails (1.4.1)
rails
graphql (1.6.3)
groupdate (3.2.0) groupdate (3.2.0)
activesupport (>= 3)
gyoku (1.3.1) gyoku (1.3.1)
builder (>= 2.1.2) builder (>= 2.1.2)
hashie (3.5.5) hashie (3.5.5)
@@ -504,6 +506,8 @@ DEPENDENCIES
foundation-rails (~> 6.2.4.0) foundation-rails (~> 6.2.4.0)
foundation_rails_helper (~> 2.0.0) foundation_rails_helper (~> 2.0.0)
fuubar fuubar
graphiql-rails (~> 1.4.1)
graphql (~> 1.6.3)
groupdate (~> 3.2.0) groupdate (~> 3.2.0)
i18n-tasks (~> 0.9.15) i18n-tasks (~> 0.9.15)
initialjs-rails (= 0.2.0.4) initialjs-rails (= 0.2.0.4)

View File

@@ -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

View File

@@ -1,5 +1,7 @@
class Comment < ActiveRecord::Base class Comment < ActiveRecord::Base
include Flaggable include Flaggable
include HasPublicAuthor
include Graphqlable
acts_as_paranoid column: :hidden_at acts_as_paranoid column: :hidden_at
include ActsAsParanoidAliases include ActsAsParanoidAliases
@@ -24,6 +26,12 @@ class Comment < ActiveRecord::Base
scope :with_visible_author, -> { joins(:user).where("users.hidden_at IS NULL") } 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 :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 :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_by_most_voted, -> { order(confidence_score: :desc, created_at: :desc) }
scope :sort_descendants_by_most_voted, -> { order(confidence_score: :desc, created_at: :asc) } scope :sort_descendants_by_most_voted, -> { order(confidence_score: :desc, created_at: :asc) }

View File

@@ -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

View File

@@ -0,0 +1,5 @@
module HasPublicAuthor
def public_author
self.author.public_activity? ? self.author : nil
end
end

View File

@@ -4,11 +4,11 @@ module Measurable
class_methods do class_methods do
def title_max_length 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 end
def responsible_name_max_length 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 end
def question_max_length def question_max_length
@@ -21,4 +21,4 @@ module Measurable
end end
end end

View File

@@ -7,6 +7,8 @@ class Debate < ActiveRecord::Base
include Sanitizable include Sanitizable
include Searchable include Searchable
include Filterable include Filterable
include HasPublicAuthor
include Graphqlable
acts_as_votable acts_as_votable
acts_as_paranoid column: :hidden_at 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 :sort_by_flags, -> { order(flags_count: :desc, updated_at: :desc) }
scope :last_week, -> { where("created_at >= ?", 7.days.ago)} scope :last_week, -> { where("created_at >= ?", 7.days.ago)}
scope :featured, -> { where("featured_at is not null")} scope :featured, -> { where("featured_at is not null")}
scope :public_for_api, -> { all }
# Ahoy setup # Ahoy setup
visitable # Ahoy will automatically assign visit_id on create visitable # Ahoy will automatically assign visit_id on create

View File

@@ -1,10 +1,15 @@
class Geozone < ActiveRecord::Base class Geozone < ActiveRecord::Base
include Graphqlable
has_many :proposals has_many :proposals
has_many :spending_proposals has_many :spending_proposals
has_many :debates has_many :debates
has_many :users has_many :users
validates :name, presence: true validates :name, presence: true
scope :public_for_api, -> { all }
def self.names def self.names
Geozone.pluck(:name) Geozone.pluck(:name)
end end

View File

@@ -1,4 +1,7 @@
class Organization < ActiveRecord::Base class Organization < ActiveRecord::Base
include Graphqlable
belongs_to :user, touch: true belongs_to :user, touch: true
validates :name, presence: true validates :name, presence: true

View File

@@ -6,6 +6,8 @@ class Proposal < ActiveRecord::Base
include Sanitizable include Sanitizable
include Searchable include Searchable
include Filterable include Filterable
include HasPublicAuthor
include Graphqlable
acts_as_votable acts_as_votable
acts_as_paranoid column: :hidden_at acts_as_paranoid column: :hidden_at
@@ -51,6 +53,7 @@ class Proposal < ActiveRecord::Base
scope :retired, -> { where.not(retired_at: nil) } scope :retired, -> { where.not(retired_at: nil) }
scope :not_retired, -> { where(retired_at: nil) } scope :not_retired, -> { where(retired_at: nil) }
scope :successful, -> { where("cached_votes_up >= ?", Proposal.votes_needed_for_success) } scope :successful, -> { where("cached_votes_up >= ?", Proposal.votes_needed_for_success) }
scope :public_for_api, -> { all }
def to_param def to_param
"#{id}-#{title}".parameterize "#{id}-#{title}".parameterize

View File

@@ -1,4 +1,7 @@
class ProposalNotification < ActiveRecord::Base class ProposalNotification < ActiveRecord::Base
include Graphqlable
belongs_to :author, class_name: 'User', foreign_key: 'author_id' belongs_to :author, class_name: 'User', foreign_key: 'author_id'
belongs_to :proposal belongs_to :proposal
@@ -7,6 +10,8 @@ class ProposalNotification < ActiveRecord::Base
validates :proposal, presence: true validates :proposal, presence: true
validate :minimum_interval validate :minimum_interval
scope :public_for_api, -> { where(proposal_id: Proposal.public_for_api.pluck(:id)) }
def minimum_interval def minimum_interval
return true if proposal.try(:notifications).blank? 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 if proposal.notifications.last.created_at > (Time.current - Setting[:proposal_notification_minimum_interval_in_days].to_i.days).to_datetime

View File

@@ -10,6 +10,8 @@ class User < ActiveRecord::Base
acts_as_paranoid column: :hidden_at acts_as_paranoid column: :hidden_at
include ActsAsParanoidAliases include ActsAsParanoidAliases
include Graphqlable
has_one :administrator has_one :administrator
has_one :moderator has_one :moderator
has_one :valuator has_one :valuator
@@ -61,6 +63,7 @@ class User < ActiveRecord::Base
scope :email_digest, -> { where(email_digest: true) } scope :email_digest, -> { where(email_digest: true) }
scope :active, -> { where(erased_at: nil) } scope :active, -> { where(erased_at: nil) }
scope :erased, -> { where.not(erased_at: nil) } scope :erased, -> { where.not(erased_at: nil) }
scope :public_for_api, -> { all }
before_validation :clean_document_number before_validation :clean_document_number
@@ -288,6 +291,18 @@ class User < ActiveRecord::Base
end end
delegate :can?, :cannot?, to: :ability 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 # overwritting of Devise method to allow login using email OR username
def self.find_for_database_authentication(warden_conditions) def self.find_for_database_authentication(warden_conditions)
conditions = warden_conditions.dup conditions = warden_conditions.dup

View File

@@ -1,2 +1,14 @@
class Vote < ActsAsVotable::Vote class Vote < ActsAsVotable::Vote
end
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

89
config/api.yml Normal file
View File

@@ -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

View File

@@ -5,6 +5,15 @@ module ActsAsTaggableOn
after_create :increment_tag_custom_counter after_create :increment_tag_custom_counter
after_destroy :touch_taggable, :decrement_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 def touch_taggable
taggable.touch if taggable.present? taggable.touch if taggable.present?
end end
@@ -20,6 +29,14 @@ module ActsAsTaggableOn
Tag.class_eval do 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) def increment_custom_counter_for(taggable_type)
Tag.increment_counter(custom_counter_field_name_for(taggable_type), id) Tag.increment_counter(custom_counter_field_name_for(taggable_type), id)
end end
@@ -42,10 +59,22 @@ module ActsAsTaggableOn
ActsAsTaggableOn::Tag.where('taggings.taggable_type' => 'SpendingProposal').includes(:taggings).order(:name).uniq ActsAsTaggableOn::Tag.where('taggings.taggable_type' => 'SpendingProposal').includes(:taggings).order(:name).uniq
end end
def self.graphql_field_name
:tag
end
def self.graphql_pluralized_field_name
:tags
end
def self.graphql_type_name
'Tag'
end
private private
def custom_counter_field_name_for(taggable_type) def custom_counter_field_name_for(taggable_type)
"#{taggable_type.underscore.pluralize}_count" "#{taggable_type.underscore.pluralize}_count"
end end
end end
end end

View File

@@ -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

View File

@@ -400,8 +400,13 @@ Rails.application.routes.draw do
root to: "dashboard#index" root to: "dashboard#index"
end end
# GraphQL
get '/graphql', to: 'graphql#query'
post '/graphql', to: 'graphql#query'
if Rails.env.development? if Rails.env.development?
mount LetterOpenerWeb::Engine, at: "/letter_opener" mount LetterOpenerWeb::Engine, at: "/letter_opener"
mount GraphiQL::Rails::Engine, at: '/graphiql', graphql_path: '/graphql'
end end
mount Tolk::Engine => '/translate', :as => 'tolk' mount Tolk::Engine => '/translate', :as => 'tolk'

View File

@@ -59,7 +59,17 @@ print "Creating Users"
def create_user(email, username = Faker::Name.name) def create_user(email, username = Faker::Name.name)
pwd = '12345678' 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 end
admin = create_user('admin@consul.dev', 'admin') admin = create_user('admin@consul.dev', 'admin')
@@ -103,11 +113,11 @@ end
official.update(official_level: i, official_position: "Official position #{i}") official.update(official_level: i, official_position: "Official position #{i}")
end end
(1..40).each do |i| (1..100).each do |i|
user = create_user("user#{i}@consul.dev") user = create_user("user#{i}@consul.dev")
level = [1, 2, 3].sample level = [1, 2, 3].sample
if level >= 2 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 end
if level == 3 if level == 3
user.update(verified_at: Time.current, document_number: Faker::Number.number(10)) user.update(verified_at: Time.current, document_number: Faker::Number.number(10))
@@ -285,7 +295,7 @@ puts " ✅"
print "Voting Debates, Proposals & Comments" print "Voting Debates, Proposals & Comments"
100.times do 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 vote = [true, false].sample
debate = Debate.reorder("RANDOM()").first debate = Debate.reorder("RANDOM()").first
debate.vote_by(voter: voter, vote: vote) debate.vote_by(voter: voter, vote: vote)
@@ -299,7 +309,7 @@ end
end end
100.times do 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 = Proposal.reorder("RANDOM()").first
proposal.vote_by(voter: voter, vote: true) proposal.vote_by(voter: voter, vote: true)
end end
@@ -487,6 +497,16 @@ Proposal.last(3).each do |proposal|
created_at: rand((Time.current - 1.week)..Time.current)) created_at: rand((Time.current - 1.week)..Time.current))
end 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 "" puts ""
print "Creating polls" print "Creating polls"

View File

@@ -232,10 +232,10 @@ ActiveRecord::Schema.define(version: 20170613203256) do
t.string "visit_id" t.string "visit_id"
t.datetime "hidden_at" t.datetime "hidden_at"
t.integer "flags_count", default: 0 t.integer "flags_count", default: 0
t.datetime "ignored_flag_at"
t.integer "cached_votes_total", default: 0 t.integer "cached_votes_total", default: 0
t.integer "cached_votes_up", default: 0 t.integer "cached_votes_up", default: 0
t.integer "cached_votes_down", default: 0 t.integer "cached_votes_down", default: 0
t.datetime "ignored_flag_at"
t.integer "comments_count", default: 0 t.integer "comments_count", default: 0
t.datetime "confirmed_hide_at" t.datetime "confirmed_hide_at"
t.integer "cached_anonymous_votes_total", default: 0 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_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", ["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", ["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", ["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", ["hidden_at"], name: "index_debates_on_hidden_at", using: :btree
add_index "debates", ["hot_score"], name: "index_debates_on_hot_score", 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", ["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", ["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", ["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", ["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", ["hidden_at"], name: "index_proposals_on_hidden_at", using: :btree
add_index "proposals", ["hot_score"], name: "index_proposals_on_hot_score", 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_digest", default: true
t.boolean "email_on_direct_message", default: true t.boolean "email_on_direct_message", default: true
t.boolean "official_position_badge", default: false 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.boolean "created_from_signature", default: false
t.integer "failed_email_digests_count", default: 0 t.integer "failed_email_digests_count", default: 0
t.text "former_users_data_log", default: "" t.text "former_users_data_log", default: ""

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -6,8 +6,9 @@ FactoryGirl.define do
sequence(:email) { |n| "manuela#{n}@consul.dev" } sequence(:email) { |n| "manuela#{n}@consul.dev" }
password 'judgmentday' password 'judgmentday'
terms_of_service '1' terms_of_service '1'
confirmed_at { Time.current } confirmed_at { Time.current }
public_activity true
trait :incomplete_verification do trait :incomplete_verification do
after :create do |user| after :create do |user|

View File

@@ -64,6 +64,75 @@ describe 'ActsAsTaggableOn' do
expect(tag.proposals_count).to eq(1) expect(tag.proposals_count).to eq(1)
end end
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
end end

View File

@@ -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

View File

@@ -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

674
spec/lib/graphql_spec.rb Normal file
View File

@@ -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

View File

@@ -4,6 +4,8 @@ describe Comment do
let(:comment) { build(:comment) } let(:comment) { build(:comment) }
it_behaves_like "has_public_author"
it "is valid" do it "is valid" do
expect(comment).to be_valid expect(comment).to be_valid
end end
@@ -132,4 +134,58 @@ describe Comment do
end end
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 end

View File

@@ -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

View File

@@ -4,6 +4,8 @@ require 'rails_helper'
describe Debate do describe Debate do
let(:debate) { build(:debate) } let(:debate) { build(:debate) }
it_behaves_like "has_public_author"
it "should be valid" do it "should be valid" do
expect(debate).to be_valid expect(debate).to be_valid
end end
@@ -700,4 +702,16 @@ describe Debate do
end end
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 end

View File

@@ -22,6 +22,28 @@ describe ProposalNotification do
expect(notification).to_not be_valid expect(notification).to_not be_valid
end 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 describe "minimum interval between notifications" do
before(:each) do before(:each) do

View File

@@ -4,6 +4,8 @@ require 'rails_helper'
describe Proposal do describe Proposal do
let(:proposal) { build(:proposal) } let(:proposal) { build(:proposal) }
it_behaves_like "has_public_author"
it "should be valid" do it "should be valid" do
expect(proposal).to be_valid expect(proposal).to be_valid
end end
@@ -843,4 +845,16 @@ describe Proposal do
end end
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 end

View File

@@ -40,4 +40,77 @@ describe 'Vote' do
expect(vote.value).to eq(false) expect(vote.value).to eq(false)
end end
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 end

View File

@@ -3,6 +3,7 @@ require 'database_cleaner'
require 'email_spec' require 'email_spec'
require 'devise' require 'devise'
require 'knapsack' require 'knapsack'
Dir["./spec/models/concerns/*.rb"].each { |f| require f }
Dir["./spec/support/**/*.rb"].sort.each { |f| require f } Dir["./spec/support/**/*.rb"].sort.each { |f| require f }
RSpec.configure do |config| RSpec.configure do |config|