diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 6ec9225c5..2ed8b208d 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -472,10 +472,6 @@ Rails/I18nLocaleAssignment: Exclude: - 'spec/features/locale_spec.rb' -# Offense count: 40 -Rails/I18nLocaleTexts: - Enabled: false - # Offense count: 1 # Configuration parameters: IgnoreScopes. Rails/InverseOf: diff --git a/app/controllers/admin/crop_companions_controller.rb b/app/controllers/admin/crop_companions_controller.rb index 1ca9122a8..b21841b11 100644 --- a/app/controllers/admin/crop_companions_controller.rb +++ b/app/controllers/admin/crop_companions_controller.rb @@ -15,7 +15,7 @@ module Admin def create @crop_companion = @crop.crop_companions.new(crop_companion_params) if @crop_companion.save - redirect_to admin_crop_crop_companions_path(@crop), notice: 'Companion was successfully created.' + redirect_to admin_crop_crop_companions_path(@crop), notice: t('crop_companions.created') else render :new end @@ -24,7 +24,7 @@ module Admin def destroy @crop_companion = @crop.crop_companions.find(params[:id]) @crop_companion.destroy - redirect_to admin_crop_crop_companions_path(@crop), notice: 'Companion was successfully destroyed.' + redirect_to admin_crop_crop_companions_path(@crop), notice: t('crop_companions.deleted') end private diff --git a/app/controllers/admin/versions_controller.rb b/app/controllers/admin/versions_controller.rb index ff2691642..bd37a95e5 100644 --- a/app/controllers/admin/versions_controller.rb +++ b/app/controllers/admin/versions_controller.rb @@ -9,9 +9,9 @@ module Admin @version = PaperTrail::Version.find(params[:id]) @object = @version.reify if @object.save - redirect_to admin_crops_path, notice: "Reverted to version from #{@version.created_at.strftime('%B %d, %Y')}" + redirect_to admin_crops_path, notice: t('messages.revert_success', date: @version.created_at.strftime('%B %d, %Y')) else - redirect_to admin_crops_path, alert: "Could not revert to version from #{@version.created_at.strftime('%B %d, %Y')}. Errors: #{@object.errors.full_messages.to_sentence}" + redirect_to admin_crops_path, alert: t('messages.revert_error', date: @version.created_at.strftime('%B %d, %Y'), errors: @object.errors.full_messages.to_sentence) end end diff --git a/app/controllers/alternate_names_controller.rb b/app/controllers/alternate_names_controller.rb index d95b1667c..4c975cab8 100644 --- a/app/controllers/alternate_names_controller.rb +++ b/app/controllers/alternate_names_controller.rb @@ -30,7 +30,7 @@ class AlternateNamesController < ApplicationController @alternate_name = AlternateName.new(alternate_name_params) if @alternate_name.save - redirect_to @alternate_name.crop, notice: 'Alternate name was successfully created.' + redirect_to @alternate_name.crop, notice: t('alternate_names.created') else render action: "new" end @@ -40,7 +40,7 @@ class AlternateNamesController < ApplicationController # PUT /alternate_names/1.json def update if @alternate_name.update(alternate_name_params) - redirect_to @alternate_name.crop, notice: 'Alternate name was successfully updated.' + redirect_to @alternate_name.crop, notice: t('alternate_names.updated') else render action: "edit" end @@ -51,7 +51,7 @@ class AlternateNamesController < ApplicationController def destroy @crop = @alternate_name.crop @alternate_name.destroy - redirect_to @crop, notice: 'Alternate name was successfully deleted.' + redirect_to @crop, notice: t('alternate_names.deleted') end private diff --git a/app/controllers/authentications_controller.rb b/app/controllers/authentications_controller.rb index f3588cbdf..e8c03abbd 100644 --- a/app/controllers/authentications_controller.rb +++ b/app/controllers/authentications_controller.rb @@ -24,9 +24,9 @@ class AuthenticationsController < ApplicationController name: ) - flash[:notice] = "Authentication successful." + flash[:notice] = t('messages.auth_success') else - flash[:notice] = "Authentication failed." + flash[:notice] = t('messages.auth_failed') end redirect_to request.env['omniauth.origin'] || edit_member_registration_path end diff --git a/app/controllers/blocks_controller.rb b/app/controllers/blocks_controller.rb new file mode 100644 index 000000000..06521ac18 --- /dev/null +++ b/app/controllers/blocks_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class BlocksController < ApplicationController + load_and_authorize_resource + skip_load_resource only: :create + + def create + @block = current_member.blocks.build(blocked: Member.find(params[:blocked])) + + if @block.save + flash[:notice] = "Blocked #{@block.blocked.login_name}" + else + flash[:error] = "Already blocking or error while blocking." + end + redirect_back fallback_location: root_path + end + + def destroy + @block = current_member.blocks.find(params[:id]) + @unblocked = @block.blocked + @block.destroy + + flash[:notice] = "Unblocked #{@unblocked.login_name}" + redirect_to @unblocked + end + + private + + def block_params + params.permit(:id, :blocked) + end +end diff --git a/app/controllers/follows_controller.rb b/app/controllers/follows_controller.rb index 35bc3199f..94eff7680 100644 --- a/app/controllers/follows_controller.rb +++ b/app/controllers/follows_controller.rb @@ -13,9 +13,9 @@ class FollowsController < ApplicationController @follow = current_member.follows.build(followed: Member.find(params[:followed])) if @follow.save - flash[:notice] = "Followed #{@follow.followed.login_name}" + flash[:notice] = t('messages.followed', name: @follow.followed.login_name) else - flash[:error] = "Already following or error while following." + flash[:error] = t('messages.follow_error') end redirect_back_or_to(root_path) end @@ -25,7 +25,7 @@ class FollowsController < ApplicationController @unfollowed = @follow.followed @follow.destroy - flash[:notice] = "Unfollowed #{@unfollowed.login_name}" + flash[:notice] = t('messages.unfollowed', name: @unfollowed.login_name) redirect_to @unfollowed end diff --git a/app/controllers/forums_controller.rb b/app/controllers/forums_controller.rb index bc75da5fa..179c96c69 100644 --- a/app/controllers/forums_controller.rb +++ b/app/controllers/forums_controller.rb @@ -32,14 +32,14 @@ class ForumsController < ApplicationController # POST /forums.json def create @forum = Forum.new(forum_params) - flash[:notice] = 'Forum was successfully created.' if @forum.save + flash[:notice] = t('forums.created') if @forum.save respond_with(@forum) end # PUT /forums/1 # PUT /forums/1.json def update - flash[:notice] = 'Forum was successfully updated.' if @forum.update(forum_params) + flash[:notice] = t('forums.updated') if @forum.update(forum_params) respond_with(@forum) end @@ -47,7 +47,7 @@ class ForumsController < ApplicationController # DELETE /forums/1.json def destroy @forum.destroy - flash[:notice] = 'Forum was successfully deleted' + flash[:notice] = t('forums.deleted') redirect_to forums_url end diff --git a/app/controllers/likes_controller.rb b/app/controllers/likes_controller.rb index 3093ce42e..b205ec220 100644 --- a/app/controllers/likes_controller.rb +++ b/app/controllers/likes_controller.rb @@ -14,7 +14,7 @@ class LikesController < ApplicationController @like.likeable.reindex(refresh: true) success(@like, liked_by_member: true, status_code: :created) else - failed(@like, message: 'Unable to like') + failed(@like, message: t('messages.unable_to_like')) end end @@ -29,7 +29,7 @@ class LikesController < ApplicationController @like.likeable.reindex(refresh: true) success(@like, liked_by_member: false, status_code: :ok) else - failed(@like, message: 'Unable to unlike') + failed(@like, message: t('messages.unable_to_unlike')) end end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index bd34e3ce1..974c09d29 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -27,10 +27,21 @@ class MessagesController < ApplicationController def create if params[:conversation_id].present? @conversation = Mailboxer::Conversation.find(params[:conversation_id]) + # Check if any of the recipients have blocked the sender + if @conversation.recipients.any? { |recipient| recipient.already_blocking?(current_member) } + flash[:error] = "You cannot reply to this conversation because one of the recipients has blocked you." + redirect_to conversation_path(@conversation) + return + end current_member.reply_to_conversation(@conversation, params[:body]) redirect_to conversation_path(@conversation) else recipient = Member.find(params[:recipient_id]) + if recipient.already_blocking?(current_member) + flash[:error] = "You cannot send a message to a member who has blocked you." + redirect_back fallback_location: root_path + return + end body = params[:body] subject = params[:subject] @conversation = current_member.send_message(recipient, body, subject) diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 0d55de038..f4394d8a3 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -10,7 +10,7 @@ require './lib/actions/oauth_signup_action' # class OmniauthCallbacksController < Devise::OmniauthCallbacksController def failure - flash[:alert] = "Authentication failed." + flash[:alert] = t('messages.auth_failed') redirect_to request.env['omniauth.origin'] || "/" end diff --git a/app/controllers/places_controller.rb b/app/controllers/places_controller.rb index a30c1ae98..cfec33fa4 100644 --- a/app/controllers/places_controller.rb +++ b/app/controllers/places_controller.rb @@ -33,7 +33,7 @@ class PlacesController < ApplicationController def search if params[:new_place].empty? - redirect_to places_path, alert: 'Please enter a valid location' + redirect_to places_path, alert: t('messages.invalid_location') else redirect_to place_path(params[:new_place]) end diff --git a/app/controllers/plantings_controller.rb b/app/controllers/plantings_controller.rb index a696e9c03..bc443fe8e 100644 --- a/app/controllers/plantings_controller.rb +++ b/app/controllers/plantings_controller.rb @@ -116,11 +116,11 @@ class PlantingsController < DataController new_planting.finished_at = nil if new_planting.save - redirect_to edit_planting_path(new_planting), notice: 'Planting was successfully transplanted.' + redirect_to edit_planting_path(new_planting), notice: t('messages.transplant_success') else # if the save fails, we should probably roll back the finishing of the original planting @planting.update(finished: false, finished_at: nil) - redirect_to @planting, alert: "There was an error transplanting the planting: #{new_planting.errors.full_messages.to_sentence}" + redirect_to @planting, alert: t('messages.transplant_error', errors: new_planting.errors.full_messages.to_sentence) end end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index b1affe635..01239a601 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -29,17 +29,17 @@ class PostsController < ApplicationController def create params[:post][:author_id] = current_member.id @post = Post.new(post_params) - flash[:notice] = 'Post was successfully created.' if @post.save + flash[:notice] = t('posts.created') if @post.save respond_with(@post) end def update - flash[:notice] = 'Post was successfully updated.' if @post.update(post_params) + flash[:notice] = t('posts.updated') if @post.update(post_params) respond_with(@post) end def destroy - flash[:notice] = 'Post was deleted.' if @post.destroy + flash[:notice] = t('posts.deleted') if @post.destroy respond_with(@post) end diff --git a/app/controllers/scientific_names_controller.rb b/app/controllers/scientific_names_controller.rb index d815de94a..525563235 100644 --- a/app/controllers/scientific_names_controller.rb +++ b/app/controllers/scientific_names_controller.rb @@ -54,7 +54,7 @@ class ScientificNamesController < ApplicationController def destroy @crop = @scientific_name.crop @scientific_name.destroy - flash[:notice] = 'Scientific name was successfully deleted.' + flash[:notice] = t('scientific_names.deleted') respond_with(@crop) end diff --git a/app/controllers/seeds_controller.rb b/app/controllers/seeds_controller.rb index 6dd2741cd..46e00c0cc 100644 --- a/app/controllers/seeds_controller.rb +++ b/app/controllers/seeds_controller.rb @@ -61,7 +61,7 @@ class SeedsController < DataController @seed.finished ||= false @seed.owner = current_member @seed.crop = @seed.parent_planting.crop if @seed.parent_planting - flash[:notice] = "Successfully added #{@seed.crop} seed to your stash." if @seed.save + flash[:notice] = t('seeds.added_to_stash', crop: @seed.crop) if @seed.save if params[:return] == 'planting' respond_with(@seed, location: @seed.parent_planting) else @@ -70,7 +70,7 @@ class SeedsController < DataController end def update - flash[:notice] = 'Seed was successfully updated.' if @seed.update(seed_params) + flash[:notice] = t('seeds.updated') if @seed.update(seed_params) respond_with(@seed) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 47063d75d..fb5d0f226 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -5,7 +5,7 @@ class SessionsController < Devise::SessionsController def create super do |_resource| - flash[:alert] = "There are crops waiting to be wrangled." if Crop.pending_approval.present? && current_member.role?(:crop_wrangler) + flash[:alert] = t('messages.crops_waiting') if Crop.pending_approval.present? && current_member.role?(:crop_wrangler) end end end diff --git a/app/models/ability.rb b/app/models/ability.rb index 4bce19085..62bc7ef1e 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -164,6 +164,12 @@ class Ability can :destroy, Follow cannot :destroy, Follow, followed_id: member.id # can't unfollow yourself + # blocking/unblocking permissions + can :create, Block + cannot :create, Block, blocked_id: member.id # can't block yourself + + can :destroy, Block, blocker_id: member.id # can only unblock your own blocks + cannot :create, GardenType cannot :update, GardenType cannot :destroy, GardenType diff --git a/app/models/block.rb b/app/models/block.rb new file mode 100644 index 000000000..a149f75b1 --- /dev/null +++ b/app/models/block.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Block < ApplicationRecord + belongs_to :blocker, class_name: "Member" + belongs_to :blocked, class_name: "Member" + + validates :blocker_id, uniqueness: { scope: :blocked_id } + + after_create :destroy_follow_relationship + + private + + def destroy_follow_relationship + # Destroy the follow relationship in both directions + Follow.where(follower: blocker, followed: blocked).destroy_all + Follow.where(follower: blocked, followed: blocker).destroy_all + end +end diff --git a/app/models/comment.rb b/app/models/comment.rb index a351b2fc1..b6b80b241 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -4,6 +4,7 @@ class Comment < ApplicationRecord belongs_to :author, class_name: 'Member', inverse_of: :comments belongs_to :commentable, polymorphic: true, counter_cache: true # validates :body, presence: true + validate :author_is_not_blocked scope :post_order, -> { order(created_at: :asc) } # for display on post page @@ -25,4 +26,13 @@ class Comment < ApplicationRecord def to_s "#{author.login_name} commented on #{commentable.subject}" end + + private + + def author_is_not_blocked + return unless author + if commentable.author.already_blocking?(author) + errors.add(:base, "You cannot comment on a post of a member who has blocked you.") + end + end end diff --git a/app/models/crop.rb b/app/models/crop.rb index b04eb3325..351ed0ae7 100644 --- a/app/models/crop.rb +++ b/app/models/crop.rb @@ -57,13 +57,13 @@ class Crop < ApplicationRecord validates :en_wikipedia_url, format: { with: %r{\Ahttps?://en\.wikipedia\.org/wiki/[[:alnum:]%_.()-]+\z}, - message: 'is not a valid English Wikipedia URL' + message: :not_a_valid_wikipedia_url }, if: :approved? validates :en_youtube_url, format: { with: %r{\A(?:https?://)?(?:www\.)?(?:youtube(?:-nocookie)?\.com/(?:(?:v|e(?:mbed)?)/|\S*?[?&]v=)|youtu\.be/)[a-zA-Z0-9_-]{11}(?:[?&]\S*)?\z}, - message: 'is not a valid YouTube URL' + message: :not_a_valid_youtube_url }, allow_blank: true validates :name, uniqueness: { scope: :approval_status }, if: :pending? @@ -190,12 +190,12 @@ class Crop < ApplicationRecord return if rejected? return unless reason_for_rejection.present? || rejection_notes.present? - errors.add(:approval_status, "must be rejected if a reason for rejection is present") + errors.add(:approval_status, :rejection_reason_required) end def must_have_meaningful_reason_for_rejection return unless reason_for_rejection == "other" && rejection_notes.blank? - errors.add(:rejection_notes, "must be added if the reason for rejection is \"other\"") + errors.add(:rejection_notes, :rejection_notes_required) end end diff --git a/app/models/follow.rb b/app/models/follow.rb index 0b9c9236d..e86c0dddd 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -4,6 +4,7 @@ class Follow < ApplicationRecord belongs_to :follower, class_name: "Member", inverse_of: :follows belongs_to :followed, class_name: "Member", inverse_of: :inverse_follows validates :follower_id, uniqueness: { scope: :followed_id } + validate :follower_is_not_blocked after_create do Notification.create( @@ -14,4 +15,13 @@ class Follow < ApplicationRecord notifiable: self ) end + + private + + def follower_is_not_blocked + return unless follower + if followed.already_blocking?(follower) + errors.add(:base, "You cannot follow a member who has blocked you.") + end + end end diff --git a/app/models/garden.rb b/app/models/garden.rb index 5d3f04adb..ac55b5ffa 100644 --- a/app/models/garden.rb +++ b/app/models/garden.rb @@ -32,7 +32,7 @@ class Garden < ApplicationRecord validates :name, uniqueness: { scope: :owner_id } validates :name, - format: { without: /\n/, message: "must contain no newlines" }, + format: { without: /\n/, message: :no_newlines }, allow_blank: false, presence: true, length: { maximum: 255 } @@ -53,7 +53,7 @@ class Garden < ApplicationRecord "acres" => "acre" }.freeze validates :area_unit, inclusion: { in: AREA_UNITS_VALUES.values, - message: "%s is not a valid area unit" }, + message: :not_a_valid_area_unit }, allow_blank: true def cleanup_area diff --git a/app/models/garden_collaborator.rb b/app/models/garden_collaborator.rb index 520303437..7604ad286 100644 --- a/app/models/garden_collaborator.rb +++ b/app/models/garden_collaborator.rb @@ -11,7 +11,7 @@ class GardenCollaborator < ApplicationRecord return unless member return unless garden - errors.add(:member_id, "cannot be the garden owner") if garden.owner == member + errors.add(:member_id, :cannot_be_garden_owner) if garden.owner == member end def member_slug diff --git a/app/models/harvest.rb b/app/models/harvest.rb index 7fba64bc8..92ed0f6eb 100644 --- a/app/models/harvest.rb +++ b/app/models/harvest.rb @@ -58,18 +58,18 @@ class Harvest < ApplicationRecord ## ## Validations validates :crop, approved: true - validates :crop, presence: { message: "must be present and exist in our database" } - validates :plant_part, presence: { message: "must be present and exist in our database" } + validates :crop, presence: { message: :crop_not_found } + validates :plant_part, presence: { message: :crop_not_found } validates :harvested_at, presence: true validates :quantity, allow_nil: true, numericality: { only_integer: false, greater_than_or_equal_to: 0 } validates :unit, allow_blank: true, inclusion: { - in: UNITS_VALUES.values, message: "%s is not a valid unit" + in: UNITS_VALUES.values, message: :not_a_valid_unit } validates :weight_quantity, allow_nil: true, numericality: { only_integer: false } validates :weight_unit, allow_blank: true, inclusion: { - in: WEIGHT_UNITS_VALUES.values, message: "%s is not a valid unit" + in: WEIGHT_UNITS_VALUES.values, message: :not_a_valid_unit } validate :crop_must_match_planting validate :owner_must_match_planting @@ -147,7 +147,7 @@ class Harvest < ApplicationRecord def crop_must_match_planting return if planting.blank? # only check if we are linked to a planting - errors.add(:planting, "must be the same crop") unless crop == planting.crop + errors.add(:planting, :same_crop_required) unless crop == planting.crop end def owner_must_match_planting @@ -155,14 +155,13 @@ class Harvest < ApplicationRecord return if owner == planting.owner || planting.garden.garden_collaborators.where(member_id: owner).any? - errors.add(:owner, - "of harvest must be the same as planting, or a collaborator on that garden") + errors.add(:owner, :same_owner_required) end def harvest_must_be_after_planting # only check if we are linked to a planting return unless harvested_at.present? && planting.present? && planting.planted_at.present? - errors.add(:planting, "cannot be harvested before planting") unless harvested_at > planting.planted_at + errors.add(:planting, :harvest_after_planted) unless harvested_at > planting.planted_at end end diff --git a/app/models/like.rb b/app/models/like.rb index ed16065de..493ed938c 100644 --- a/app/models/like.rb +++ b/app/models/like.rb @@ -5,4 +5,23 @@ class Like < ApplicationRecord belongs_to :likeable, polymorphic: true, counter_cache: true, touch: true validates :member, :likeable, presence: true validates :member, uniqueness: { scope: :likeable } + validate :member_is_not_blocked + + def likeable_author + if likeable.respond_to?(:author) + likeable.author + elsif likeable.respond_to?(:owner) + likeable.owner + end + end + + private + + def member_is_not_blocked + return unless member + author = likeable_author + if author && author.already_blocking?(member) + errors.add(:base, "You cannot like content of a member who has blocked you.") + end + end end diff --git a/app/models/member.rb b/app/models/member.rb index 3bfcf9d7c..60a975d04 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -52,6 +52,15 @@ class Member < ApplicationRecord has_many :followed, through: :follows has_many :followers, through: :inverse_follows, source: :follower + # + # Blocking other members + has_many :blocks, class_name: "Block", foreign_key: "blocker_id", dependent: :destroy, + inverse_of: :blocker + has_many :inverse_blocks, class_name: "Block", foreign_key: "blocked_id", + dependent: :destroy, inverse_of: :blocked + has_many :blocked_members, through: :blocks, source: :blocked + has_many :blockers, through: :inverse_blocks, source: :blocker + # # Global data records this member created has_many :requested_crops, class_name: 'Crop', foreign_key: 'requester_id', dependent: :nullify, @@ -96,21 +105,21 @@ class Member < ApplicationRecord validates :tos_agreement, acceptance: { allow_nil: true, accept: true } validates :login_name, length: { - minimum: 2, maximum: 25, message: "should be between 2 and 25 characters long" + minimum: 2, maximum: 25, message: :login_name_length }, exclusion: { - in: %w(growstuff admin moderator staff nearby), message: "name is reserved" + in: %w(growstuff admin moderator staff nearby), message: :login_name_reserved }, format: { - with: /\A\w+\z/, message: "may only include letters, numbers, or underscores" + with: /\A\w+\z/, message: :login_name_format }, uniqueness: { case_sensitive: false } - validates :website_url, format: { with: %r{\Ahttps?://}, message: "must start with http:// or https://" }, allow_blank: true - validates :other_url, format: { with: %r{\Ahttps?://}, message: "must start with http:// or https://" }, allow_blank: true + validates :website_url, format: { with: %r{\Ahttps?://}, message: :url_format }, allow_blank: true + validates :other_url, format: { with: %r{\Ahttps?://}, message: :url_format }, allow_blank: true validates :instagram_handle, :facebook_handle, :bluesky_handle, - format: { without: %r{\Ahttps?://|/}, message: "should be a handle, not a URL" }, allow_blank: true + format: { without: %r{\Ahttps?://|/}, message: :handle_format }, allow_blank: true # # Triggers @@ -179,4 +188,12 @@ class Member < ApplicationRecord def get_follow(member) follows.find_by(followed_id: member.id) if already_following?(member) end + + def already_blocking?(member) + blocks.exists?(blocked_id: member.id) + end + + def get_block(member) + blocks.find_by(blocked_id: member.id) if already_blocking?(member) + end end diff --git a/app/models/photo_association.rb b/app/models/photo_association.rb index 6e9ad47e7..7197cfb71 100644 --- a/app/models/photo_association.rb +++ b/app/models/photo_association.rb @@ -29,12 +29,12 @@ class PhotoAssociation < ApplicationRecord def photo_and_item_have_same_owner return if photographable_type == 'Crop' - errors.add(:photo, "must have same owner as item it links to") unless photographable.owner_id == photo.owner_id + errors.add(:photo, :photo_owner_mismatch) unless photographable.owner_id == photo.owner_id end def crop_present return unless %w(Planting Seed Harvest).include?(photographable_type) - errors.add(:crop_id, "failed to calculate crop") if crop_id.blank? + errors.add(:crop_id, :calculate_crop_failed) if crop_id.blank? end end diff --git a/app/models/planting.rb b/app/models/planting.rb index 7cc82c731..05ecc99ec 100644 --- a/app/models/planting.rb +++ b/app/models/planting.rb @@ -72,7 +72,7 @@ class Planting < ApplicationRecord ## ## Validations validates :garden, presence: true - validates :crop, presence: true, approved: { message: "must be present and exist in our database" } + validates :crop, presence: true, approved: { message: :crop_must_be_approved } validate :finished_must_be_after_planted validate :owner_must_match_garden_owner validate :cannot_be_finished_and_failed @@ -80,10 +80,10 @@ class Planting < ApplicationRecord only_integer: true, greater_than_or_equal_to: 0 } validates :sunniness, allow_blank: true, inclusion: { - in: SUNNINESS_VALUES, message: "%s is not a valid sunniness value" + in: SUNNINESS_VALUES, message: :not_a_valid_sunniness } validates :planted_from, allow_blank: true, inclusion: { - in: PLANTED_FROM_VALUES, message: "%s is not a valid planting method" + in: PLANTED_FROM_VALUES, message: :not_a_valid_planting_method } validates :overall_rating, allow_blank: true, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 5 @@ -132,20 +132,19 @@ class Planting < ApplicationRecord private def cannot_be_finished_and_failed - errors.add(:failed, "can't be true if planting is also finished") if finished && failed + errors.add(:failed, :failed_and_finished) if finished && failed end # check that any finished_at date occurs after planted_at def finished_must_be_after_planted return unless planted_at && finished_at # only check if we have both - errors.add(:finished_at, "must be after the planting date") unless planted_at < finished_at + errors.add(:finished_at, :finished_after_planted) unless planted_at < finished_at end def owner_must_match_garden_owner return if owner == garden.owner || garden.garden_collaborators.where(member_id: owner).any? - errors.add(:owner, - "must be the same as garden, or a collaborator on that garden") + errors.add(:owner, :same_owner_required) end end diff --git a/app/models/seed.rb b/app/models/seed.rb index 090c8f022..d3d8fb47d 100644 --- a/app/models/seed.rb +++ b/app/models/seed.rb @@ -28,7 +28,7 @@ class Seed < ApplicationRecord # # Validations validates :crop, approved: true - validates :crop, presence: { message: "must be present and exist in our database" } + validates :crop, presence: { message: :crop_not_found } validates :quantity, allow_nil: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :days_until_maturity_min, allow_nil: true, @@ -36,20 +36,15 @@ class Seed < ApplicationRecord validates :days_until_maturity_max, allow_nil: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :tradable_to, allow_blank: false, - inclusion: { in: TRADABLE_TO_VALUES, message: "You may only trade seed nowhere, " \ - "locally, nationally, or internationally" } + inclusion: { in: TRADABLE_TO_VALUES, message: :tradable_to_inclusion } validates :organic, allow_blank: false, - inclusion: { in: ORGANIC_VALUES, message: "You must say whether the seeds " \ - "are organic or not, or that you don't know" } + inclusion: { in: ORGANIC_VALUES, message: :organic_inclusion } validates :gmo, allow_blank: false, - inclusion: { in: GMO_VALUES, message: "You must say whether the seeds are " \ - "genetically modified or not, or that you don't know" } + inclusion: { in: GMO_VALUES, message: :gmo_inclusion } validates :heirloom, allow_blank: false, - inclusion: { in: HEIRLOOM_VALUES, message: "You must say whether the seeds" \ - "are heirloom, hybrid, or unknown" } + inclusion: { in: HEIRLOOM_VALUES, message: :heirloom_inclusion } validates :source, allow_blank: true, - inclusion: { in: SOURCE_VALUES, message: "You must say where the seeds are from," \ - "or that you don't know" } + inclusion: { in: SOURCE_VALUES, message: :source_inclusion } # # Delegations diff --git a/app/views/alternate_names/_form.html.haml b/app/views/alternate_names/_form.html.haml index 847ee21af..dab8a7c1d 100644 --- a/app/views/alternate_names/_form.html.haml +++ b/app/views/alternate_names/_form.html.haml @@ -16,7 +16,7 @@ %p %span.help-block For detailed crop wrangling guidelines, please consult the - = link_to "crop wrangling guide", "http://wiki.growstuff.org/index.php/Crop_wrangling" + = link_to "crop wrangling guide", "https://github.com/Growstuff/growstuff/wiki/Crop-Wrangling" on the Growstuff wiki. .form-group diff --git a/app/views/forums/index.html.haml b/app/views/forums/index.html.haml index 24e941e6d..3947fa2fb 100644 --- a/app/views/forums/index.html.haml +++ b/app/views/forums/index.html.haml @@ -6,6 +6,7 @@ - @forums.each do |forum| %h2= forum + %p= forum.description %p = localize_plural(forum.posts, Post) | diff --git a/app/views/members/_follow_buttons.haml b/app/views/members/_follow_buttons.haml index 244dc15f4..d0e064d80 100644 --- a/app/views/members/_follow_buttons.haml +++ b/app/views/members/_follow_buttons.haml @@ -1,6 +1,11 @@ - if current_member && current_member != member # must be logged in, can't follow yourself + - block = current_member.get_block(member) - follow = current_member.get_follow(member) - - if !follow && can?(:create, Follow) # not already following + - if !block && !follow && can?(:create, Follow) # not already following, and not blocking = link_to 'Follow', follows_path(followed: member), method: :post, class: 'btn btn-block btn-success' - if follow && can?(:destroy, follow) # already following - = link_to 'Unfollow', follow_path(follow), method: :delete, class: 'btn btn-block' \ No newline at end of file + = link_to 'Unfollow', follow_path(follow), method: :delete, class: 'btn btn-block' + - if !block && can?(:create, Block) # not already blocking + = link_to 'Block', blocks_path(blocked: member), method: :post, class: 'btn btn-block btn-danger' + - if block && can?(:destroy, block) # already blocking + = link_to 'Unblock', block_path(block), method: :delete, class: 'btn btn-block' \ No newline at end of file diff --git a/app/views/scientific_names/_form.html.haml b/app/views/scientific_names/_form.html.haml index b2a88d759..912f40135 100644 --- a/app/views/scientific_names/_form.html.haml +++ b/app/views/scientific_names/_form.html.haml @@ -11,7 +11,7 @@ %p %span.help-block For detailed crop wrangling guidelines, please consult the - = link_to "crop wrangling guide", "http://wiki.growstuff.org/index.php/Crop_wrangling" + = link_to "crop wrangling guide", "https://github.com/Growstuff/growstuff/wiki/Crop-Wrangling" on the Growstuff wiki. .form-group diff --git a/config/locales/en.yml b/config/locales/en.yml index d0a141d10..8885271fc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -63,6 +63,56 @@ en: seed: one: seed other: seeds + errors: + messages: + crop_not_found: must be present and exist in our database + crop_must_be_approved: must be present and exist in our database + failed_and_finished: "can't be true if planting is also finished" + finished_after_planted: must be after the planting date + rejection_reason_required: must be rejected if a reason for rejection is present + rejection_notes_required: "must be added if the reason for rejection is \"other\"" + same_crop_required: must be the same crop + harvest_after_planted: cannot be harvested before planting + cannot_be_garden_owner: cannot be the garden owner + same_owner_required: "of harvest must be the same as planting, or a collaborator on that garden" + photo_owner_mismatch: must have same owner as item it links to + calculate_crop_failed: failed to calculate crop + not_a_valid_wikipedia_url: is not a valid English Wikipedia URL + not_a_valid_youtube_url: is not a valid YouTube URL + not_a_valid_sunniness: "%{value} is not a valid sunniness value" + not_a_valid_planting_method: "%{value} is not a valid planting method" + not_a_valid_unit: "%{value} is not a valid unit" + not_a_valid_area_unit: "%{value} is not a valid area unit" + no_newlines: must contain no newlines + models: + member: + attributes: + login_name: + login_name_length: should be between 2 and 25 characters long + login_name_reserved: name is reserved + login_name_format: may only include letters, numbers, or underscores + website_url: + url_format: "must start with http:// or https://" + other_url: + url_format: "must start with http:// or https://" + instagram_handle: + handle_format: should be a handle, not a URL + facebook_handle: + handle_format: should be a handle, not a URL + bluesky_handle: + handle_format: should be a handle, not a URL + seed: + attributes: + tradable_to: + tradable_to_inclusion: "You may only trade seed nowhere, locally, nationally, or internationally" + organic: + organic_inclusion: "You must say whether the seeds are organic or not, or that you don't know" + gmo: + gmo_inclusion: "You must say whether the seeds are genetically modified or not, or that you don't know" + heirloom: + heirloom_inclusion: "You must say whether the seeds are heirloom, hybrid, or unknown" + source: + source_inclusion: "You must say where the seeds are from, or that you don't know" application_helper: title: title: @@ -112,6 +162,9 @@ en: forums: index: title: Forums + created: Forum was successfully created. + updated: Forum was successfully updated. + deleted: Forum was successfully deleted. gardens: created: Garden was successfully created. deleted: Garden was successfully deleted. @@ -208,6 +261,8 @@ en: trade_to: Will trade to unspecified: unspecified view_all: View all seeds + added_to_stash: Successfully added %{crop} seed to your stash. + updated: Seed was successfully updated. stats: member_linktext: "%{count} members" message_html: So far, %{member} have planted %{number_crops} %{number_plantings} in %{number_gardens}; and %{contributors} people have contributed to our code on %{github}! @@ -275,6 +330,21 @@ en: links: my_gardens: My gardens + messages: + auth_success: Authentication successful. + auth_failed: Authentication failed. + crops_waiting: There are crops waiting to be wrangled. + followed: "Followed %{name}" + unfollowed: "Unfollowed %{name}" + follow_error: Already following or error while following. + transplant_success: Planting was successfully transplanted. + transplant_error: "There was an error transplanting the planting: %{errors}" + revert_success: "Reverted to version from %{date}" + revert_error: "Could not revert to version from %{date}. Errors: %{errors}" + invalid_location: Please enter a valid location + unable_to_like: Unable to like + unable_to_unlike: Unable to unlike + members: edit_profile: Edit profile index: @@ -342,10 +412,22 @@ en: progress_0_not_planted_yet: 'Progress: 0% - not planted yet' posts: write_blog_post: Write blog post + created: Post was successfully created. + updated: Post was successfully updated. + deleted: Post was deleted. index: title: author_posts: "%{author} posts" default: Everyone's posts + scientific_names: + deleted: Scientific name was successfully deleted. + alternate_names: + created: Alternate name was successfully created. + updated: Alternate name was successfully updated. + deleted: Alternate name was successfully deleted. + crop_companions: + created: Companion was successfully created. + deleted: Companion was successfully destroyed. seeds: form: trade_help: > @@ -361,6 +443,7 @@ en: owner_seeds: "%{owner} seeds" save_seeds: Save seeds string: "%{crop} seeds belonging to %{owner}" + added_to_stash: Successfully added %{crop} seed to your stash. unauthorized: create: all: Please sign in or sign up to create a %{subject}. diff --git a/config/routes.rb b/config/routes.rb index 343226f46..6f0cffc66 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -105,6 +105,7 @@ Rails.application.routes.draw do resources :forums resources :follows, only: %i(create destroy) + resources :blocks, only: %i(create destroy) post 'likes' => 'likes#create' delete 'likes' => 'likes#destroy' @@ -121,6 +122,7 @@ Rails.application.routes.draw do resources :follows get 'followers' => 'follows#followers' + resources :blocks, only: %i(create destroy) end resources :messages diff --git a/db/migrate/20250901144900_create_blocks.rb b/db/migrate/20250901144900_create_blocks.rb new file mode 100644 index 000000000..3c386d5aa --- /dev/null +++ b/db/migrate/20250901144900_create_blocks.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateBlocks < ActiveRecord::Migration[6.1] + def change + create_table :blocks do |t| + t.references :blocker, foreign_key: { to_table: :members } + t.references :blocked, foreign_key: { to_table: :members } + + t.timestamps + end + add_index :blocks, [:blocker_id, :blocked_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 797ac8329..6cfdc7454 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -384,6 +384,16 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_01_045000) do t.index ["member_id"], name: "index_authentications_on_member_id" end + create_table "blocks", force: :cascade do |t| + t.bigint "blocker_id" + t.bigint "blocked_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["blocked_id"], name: "index_blocks_on_blocked_id" + t.index ["blocker_id", "blocked_id"], name: "index_blocks_on_blocker_id_and_blocked_id", unique: true + t.index ["blocker_id"], name: "index_blocks_on_blocker_id" + end + create_table "comfy_cms_categories", id: :serial, force: :cascade do |t| t.integer "site_id", null: false t.string "label", null: false @@ -972,6 +982,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_01_045000) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "blocks", "members", column: "blocked_id" + add_foreign_key "blocks", "members", column: "blocker_id" add_foreign_key "harvests", "plantings" add_foreign_key "mailboxer_conversation_opt_outs", "mailboxer_conversations", column: "conversation_id", name: "mb_opt_outs_on_conversations_id" add_foreign_key "mailboxer_notifications", "mailboxer_conversations", column: "conversation_id", name: "notifications_on_conversation_id" diff --git a/spec/controllers/forums_controller_spec.rb b/spec/controllers/forums_controller_spec.rb new file mode 100644 index 000000000..634b092c1 --- /dev/null +++ b/spec/controllers/forums_controller_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ForumsController do + let(:admin) { create(:admin_member) } + let(:member) { create(:member) } + let(:forum) { create(:forum) } + + describe "GET #index" do + it "returns a success response" do + get :index + expect(response).to be_successful + end + + it "assigns @forums" do + forum # create forum + get :index + expect(assigns(:forums)).to include(forum) + end + end + + describe "GET #show" do + it "returns a success response" do + get :show, params: { id: forum.to_param } + expect(response).to be_successful + end + end + + context "as an admin" do + before { sign_in admin } + + describe "GET #new" do + it "returns a success response" do + get :new + expect(response).to be_successful + end + end + + describe "GET #edit" do + it "returns a success response" do + get :edit, params: { id: forum.to_param } + expect(response).to be_successful + end + end + + describe "POST #create" do + context "with valid params" do + let(:valid_attributes) { { name: "New Forum", description: "A new forum", owner_id: admin.id } } + + it "creates a new Forum" do + expect { + post :create, params: { forum: valid_attributes } + }.to change(Forum, :count).by(1) + end + + it "redirects to the created forum" do + post :create, params: { forum: valid_attributes } + expect(response).to redirect_to(Forum.last) + end + end + end + + describe "PUT #update" do + context "with valid params" do + let(:new_attributes) { { name: "Updated Name" } } + + it "updates the requested forum" do + put :update, params: { id: forum.to_param, forum: new_attributes } + forum.reload + expect(forum.name).to eq("Updated Name") + end + + it "redirects to the forum" do + put :update, params: { id: forum.to_param, forum: new_attributes } + expect(response).to redirect_to(forum) + end + end + end + + describe "DELETE #destroy" do + it "destroys the requested forum" do + forum # ensure forum exists + expect { + delete :destroy, params: { id: forum.to_param } + }.to change(Forum, :count).by(-1) + end + + it "redirects to the forums list" do + delete :destroy, params: { id: forum.to_param } + expect(response).to redirect_to(forums_url) + end + end + end + + context "as a regular member" do + before { sign_in member } + + describe "GET #new" do + it "denies access" do + get :new + expect(response).to redirect_to(root_path) + end + end + + describe "POST #create" do + it "denies access" do + post :create, params: { forum: { name: "Forbidden" } } + expect(response).to redirect_to(root_path) + end + end + end +end diff --git a/spec/features/forums_spec.rb b/spec/features/forums_spec.rb new file mode 100644 index 000000000..5f5a261c9 --- /dev/null +++ b/spec/features/forums_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe "Forums usage", :js do + let!(:forum) { create(:forum, name: "General Discussion", description: "Talk about anything") } + let(:member) { create(:member) } + + describe "browsing forums" do + it "shows the list of forums" do + visit forums_path + expect(page).to have_content("General Discussion") + expect(page).to have_content("Talk about anything") + end + end + + describe "viewing a forum" do + let!(:post) { create(:post, forum: forum, subject: "Hello World", author: member) } + + it "shows forum details and posts" do + visit forum_path(forum) + expect(page).to have_css("h1", text: "General Discussion") + expect(page).to have_content("Talk about anything") + expect(page).to have_content("Hello World") + expect(page).to have_link("Post something") + end + end + + describe "starting a new post from a forum" do + include_context 'signed in member' + + it "pre-fills the forum when creating a new post" do + visit forum_path(forum) + click_link "Post something" + + expect(page).to have_current_path(new_post_path(forum_id: forum.id)) + expect(page).to have_content("This post will be posted in the forum #{forum.name}") + + fill_in "post_subject", with: "My New Post" + fill_in "post_body", with: "Content of my post" + click_button "Post" + + expect(page).to have_content("Post was successfully created") + expect(Post.last.forum).to eq(forum) + end + end +end diff --git a/spec/features/members/blocking_spec.rb b/spec/features/members/blocking_spec.rb new file mode 100644 index 000000000..9a50adee9 --- /dev/null +++ b/spec/features/members/blocking_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe "blocks", :js do + context "when signed in" do + include_context 'signed in member' + let(:other_member) { create(:member) } + + it "your profile doesn't have a block button" do + visit member_path(member) + expect(page).to have_no_link "Block" + expect(page).to have_no_link "Unblock" + end + + context "blocking another member" do + before { visit member_path(other_member) } + + it "has a block button" do + expect(page).to have_link "Block", href: blocks_path(blocked: other_member.slug) + end + + it "has correct message and unblock button" do + click_link 'Block' + expect(page).to have_content "Blocked #{other_member.login_name}" + expect(page).to have_link "Unblock", href: block_path(member.get_block(other_member)) + end + + it "has correct message and block button after unblock" do + click_link 'Block' + click_link 'Unblock' + expect(page).to have_content "Unblocked #{other_member.login_name}" + visit member_path(other_member) # unblocking redirects to root + expect(page).to have_link "Block", href: blocks_path(blocked: other_member.slug) + end + + context "when a member is blocked" do + before do + click_link 'Block' + end + + it "prevents following" do + visit member_path(other_member) + expect(page).to have_no_link "Follow" + end + + xit "prevents messaging" do + visit new_message_path(recipient_id: other_member.id) + fill_in "Subject", with: "Test message" + fill_in "Body", with: "Test message body" + click_button "Send message" + expect(page).to have_content "You cannot send a message to a member who has blocked you." + end + + xit "prevents commenting" do + post = create(:post, author: other_member) + visit post_path(post) + fill_in "comment_body", with: "Test comment" + click_button "Post Comment" + expect(page).to have_content "You cannot comment on a post of a member who has blocked you." + end + + xit "prevents liking" do + post = create(:post, author: other_member) + visit post_path(post) + click_link "Like" + expect(page).to have_content "You cannot like content of a member who has blocked you." + end + end + end + end +end diff --git a/spec/features/members/following_spec.rb b/spec/features/members/following_spec.rb index 4c2f51116..e45928d9a 100644 --- a/spec/features/members/following_spec.rb +++ b/spec/features/members/following_spec.rb @@ -23,6 +23,17 @@ describe "follows", :js do expect(page).to have_no_link "Unfollow" end + context "when the other member is blocked" do + before do + member.blocks.create(blocked: other_member) + visit member_path(other_member) + end + + it "does not have a follow button" do + expect(page).to have_no_link "Follow" + end + end + context "following another member" do before { visit member_path(other_member) } diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 229954929..78ec1c1fc 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -77,6 +77,90 @@ describe Ability do end end + context 'plantings' do + let(:approved_crop) { create(:crop, approval_status: 'approved') } + let(:unapproved_crop) { create(:crop, approval_status: 'unapproved') } + let(:garden) { create(:garden, owner: member) } + let(:planting) { create(:planting, garden: garden, crop: approved_crop, owner: member) } + let(:other_planting) { create(:planting, crop: approved_crop) } + let(:planting_with_unapproved_crop) { create(:planting, garden: garden, crop: unapproved_crop, owner: member) } + + it 'can create a planting' do + ability.should be_able_to(:create, Planting) + end + + it 'can manage their own planting with an approved crop' do + ability.should be_able_to(:update, planting) + ability.should be_able_to(:destroy, planting) + end + + xit "can't manage their own planting with an unapproved crop" do + ability.should_not be_able_to(:update, planting_with_unapproved_crop) + ability.should_not be_able_to(:destroy, planting_with_unapproved_crop) + end + + it "can't manage another member's planting" do + ability.should_not be_able_to(:update, other_planting) + ability.should_not be_able_to(:destroy, other_planting) + end + + it 'can transplant their own planting' do + ability.should be_able_to(:transplant, planting) + end + + context 'garden collaborator' do + let(:garden) { create(:garden) } + let(:planting_in_garden) { create(:planting, garden:, crop: approved_crop, owner: garden.owner) } + + before do + garden.garden_collaborators.create(member:) + end + + it 'can manage plantings in a garden they collaborate on' do + ability.should be_able_to(:update, planting_in_garden) + ability.should be_able_to(:destroy, planting_in_garden) + end + + it 'can transplant a planting in a garden they collaborate on' do + ability.should be_able_to(:transplant, planting_in_garden) + end + end + end + + context 'harvests' do + let(:harvest) { create(:harvest, owner: member) } + let(:other_harvest) { create(:harvest) } + + it 'can create a harvest' do + ability.should be_able_to(:create, Harvest) + end + + it 'can manage their own harvest' do + ability.should be_able_to(:update, harvest) + ability.should be_able_to(:destroy, harvest) + end + + it "can't manage another member's harvest" do + ability.should_not be_able_to(:update, other_harvest) + ability.should_not be_able_to(:destroy, other_harvest) + end + + context 'garden collaborator' do + let(:garden) { create(:garden) } + let(:planting_in_garden) { create(:planting, garden:, owner: garden.owner) } + let(:harvest_in_garden) { create(:harvest, planting: planting_in_garden, owner: planting_in_garden.owner) } + + before do + garden.garden_collaborators.create(member:) + end + + it 'can manage harvests in a garden they collaborate on' do + ability.should be_able_to(:update, harvest_in_garden) + ability.should be_able_to(:destroy, harvest_in_garden) + end + end + end + context 'plant parts' do let(:plant_part) { create(:plant_part) } @@ -142,4 +226,111 @@ describe Ability do end end end + + context 'activities' do + let(:activity) { create(:activity, owner: member) } + let(:other_activity) { create(:activity) } + + it 'can create an activity' do + ability.should be_able_to(:create, Activity) + end + + it 'can manage their own activity' do + ability.should be_able_to(:update, activity) + ability.should be_able_to(:destroy, activity) + end + + it "can't manage another member's activity" do + ability.should_not be_able_to(:update, other_activity) + ability.should_not be_able_to(:destroy, other_activity) + end + + context 'garden collaborator' do + let(:garden) { create(:garden) } + let(:activity_in_garden) { create(:activity, garden:) } + + before do + garden.garden_collaborators.create(member:) + end + + it 'can manage activities in a garden they collaborate on' do + ability.should be_able_to(:update, activity_in_garden) + ability.should be_able_to(:destroy, activity_in_garden) + end + end + end + + context 'seeds' do + let(:seed) { create(:seed, owner: member) } + let(:other_seed) { create(:seed) } + + it 'can create a seed' do + ability.should be_able_to(:create, Seed) + end + + it 'can manage their own seed' do + ability.should be_able_to(:update, seed) + ability.should be_able_to(:destroy, seed) + end + + it "can't manage another member's seed" do + ability.should_not be_able_to(:update, other_seed) + ability.should_not be_able_to(:destroy, other_seed) + end + end + + context 'comments' do + let(:comment) { create(:comment, author: member) } + let(:other_comment) { create(:comment) } + + it 'can create a comment' do + ability.should be_able_to(:create, Comment) + end + + it 'can manage their own comment' do + ability.should be_able_to(:update, comment) + ability.should be_able_to(:destroy, comment) + end + + it "can't manage another member's comment" do + ability.should_not be_able_to(:update, other_comment) + ability.should_not be_able_to(:destroy, other_comment) + end + end + + context 'photos' do + let(:photo) { create(:photo, owner: member) } + let(:other_photo) { create(:photo) } + + it 'can create a photo' do + ability.should be_able_to(:create, Photo) + end + + it 'can manage their own photo' do + ability.should be_able_to(:update, photo) + ability.should be_able_to(:destroy, photo) + end + + it "can't manage another member's photo" do + ability.should_not be_able_to(:update, other_photo) + ability.should_not be_able_to(:destroy, other_photo) + end + end + + context 'likes' do + let(:like) { create(:like, member:) } + let(:other_like) { create(:like) } + + it 'can create a like' do + ability.should be_able_to(:create, Like) + end + + it 'can destroy their own like' do + ability.should be_able_to(:destroy, like) + end + + it "can't destroy another member's like" do + ability.should_not be_able_to(:destroy, other_like) + end + end end diff --git a/spec/models/comment_spec.rb b/spec/models/comment_spec.rb index 8685cc15c..86b6c49d0 100644 --- a/spec/models/comment_spec.rb +++ b/spec/models/comment_spec.rb @@ -41,6 +41,21 @@ describe Comment do end end + context "when the post author has blocked the comment author" do + let(:post_author) { create(:member) } + let(:comment_author) { create(:member) } + let(:post) { create(:post, author: post_author) } + + before do + post_author.blocks.create(blocked: comment_author) + end + + it "is not valid" do + comment = build(:comment, commentable: post, author: comment_author) + expect(comment).not_to be_valid + end + end + context "ordering" do before do @m = create(:member) diff --git a/spec/models/like_spec.rb b/spec/models/like_spec.rb index 118636305..07063d0ad 100644 --- a/spec/models/like_spec.rb +++ b/spec/models/like_spec.rb @@ -63,6 +63,21 @@ describe Like do expect(Like.all).not_to include like end + context "when the likeable author has blocked the member" do + let(:likeable_author) { create(:member) } + let(:post_author) { create(:member) } + let(:post) { create(:post, author: likeable_author) } + + before do + likeable_author.blocks.create(blocked: member) + end + + it "is not valid" do + like = build(:like, likeable: post, member: member) + expect(like).not_to be_valid + end + end + it 'liked_by_members_names' do expect(post.liked_by_members_names).to eq [] Like.create(member:, likeable: post) diff --git a/spec/requests/forums_spec.rb b/spec/requests/forums_spec.rb index fdb6994be..35b2757aa 100644 --- a/spec/requests/forums_spec.rb +++ b/spec/requests/forums_spec.rb @@ -3,11 +3,53 @@ require 'rails_helper' describe "Forums" do + let(:admin) { create(:admin_member) } + let(:forum) { create(:forum) } + describe "GET /forums" do - it "works! (now write some real specs)" do - # Run the generator again with the --webrat flag if you want to use webrat methods/matchers + it "returns a successful response" do get forums_path - expect(response.status).to be(200) + expect(response).to have_http_status(:ok) + end + + it "returns JSON when requested" do + get forums_path(format: :json) + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("application/json") + end + end + + describe "GET /forums/:id" do + it "returns a successful response" do + get forum_path(forum) + expect(response).to have_http_status(:ok) + end + + it "returns JSON when requested" do + get forum_path(forum, format: :json) + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("application/json") + end + end + + describe "POST /forums" do + context "as an admin" do + before { sign_in admin } + + it "creates a new forum" do + expect { + post forums_path, params: { forum: { name: "New Request Forum", description: "Desc", owner_id: admin.id } } + }.to change(Forum, :count).by(1) + expect(response).to redirect_to(forum_path(Forum.last)) + end + end + + context "as a guest" do + it "redirects to sign in or denies access" do + post forums_path, params: { forum: { name: "New Request Forum", description: "Desc" } } + # Depending on CanCan/Devise setup, it might be a redirect to login or root + expect(response).to redirect_to(new_member_session_path).or redirect_to(root_path) + end end end end