diff --git a/app/controllers/organization_alliances_controller.rb b/app/controllers/organization_alliances_controller.rb new file mode 100644 index 00000000..2078158f --- /dev/null +++ b/app/controllers/organization_alliances_controller.rb @@ -0,0 +1,74 @@ +class OrganizationAlliancesController < ApplicationController + before_action :authenticate_user! + before_action :member_should_exist_and_be_active + before_action :authorize_admin + before_action :find_alliance, only: [:update, :destroy] + + def index + @status = params[:status] || "pending" + + @alliances = case @status + when "pending" + current_organization.pending_alliances.includes(:source_organization, :target_organization) + when "accepted" + current_organization.accepted_alliances.includes(:source_organization, :target_organization) + when "rejected" + current_organization.rejected_alliances.includes(:source_organization, :target_organization) + else + [] + end + end + + def create + alliance = OrganizationAlliance.new( + source_organization: current_organization, + target_organization_id: alliance_params[:target_organization_id], + status: "pending" + ) + + if alliance.save + flash[:notice] = t("organization_alliances.created") + else + flash[:error] = alliance.errors.full_messages.to_sentence + end + + redirect_back fallback_location: organizations_path + end + + def update + if @alliance.update(status: params[:status]) + flash[:notice] = t("organization_alliances.updated") + else + flash[:error] = @alliance.errors.full_messages.to_sentence + end + + redirect_to organization_alliances_path + end + + def destroy + if @alliance.destroy + flash[:notice] = t("organization_alliances.destroyed") + else + flash[:error] = t("organization_alliances.error_destroying") + end + + redirect_to organization_alliances_path + end + + private + + def find_alliance + @alliance = OrganizationAlliance.find(params[:id]) + end + + def authorize_admin + return if current_user.manages?(current_organization) + + flash[:error] = t("organization_alliances.not_authorized") + redirect_to root_path + end + + def alliance_params + params.require(:organization_alliance).permit(:target_organization_id) + end +end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 5a8e3616..23ff7ecc 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -6,10 +6,14 @@ class PostsController < ApplicationController def index context = model.active.of_active_members - if current_organization.present? - context = context.where( - organization_id: current_organization.id - ) + if current_user.present? && current_organization.present? + if params[:show_allied].present? + allied_org_ids = current_organization.allied_organizations.pluck(:id) + org_ids = [current_organization.id] + allied_org_ids + context = context.by_organizations(org_ids) + elsif !params[:org].present? + context = context.by_organization(current_organization.id) + end end posts = apply_scopes(context) @@ -98,15 +102,6 @@ def post_params end end - # TODO: remove this horrible hack ASAP - # - # This hack set the current organization to the post's - # organization, both in session and controller instance variable. - # - # Before changing the current organization it's important to check that - # the current_user is an active member of the organization. - # - # @param organization [Organization] def update_current_organization!(organization) return unless current_user && current_user.active?(organization) diff --git a/app/helpers/organizations_helper.rb b/app/helpers/organizations_helper.rb new file mode 100644 index 00000000..59b8decd --- /dev/null +++ b/app/helpers/organizations_helper.rb @@ -0,0 +1,25 @@ +module OrganizationsHelper + def filterable_organizations + Organization.all.order(:name) + end + + def allied_organizations + return [] unless current_organization + + allied_org_ids = current_organization.accepted_alliances.map do |alliance| + alliance.source_organization_id == current_organization.id ? + alliance.target_organization_id : alliance.source_organization_id + end + + organizations = Organization.where(id: allied_org_ids + [current_organization.id]) + organizations.order(:name) + end + + def alliance_initiator?(alliance) + alliance.source_organization_id == current_organization.id + end + + def alliance_recipient(alliance) + alliance_initiator?(alliance) ? alliance.target_organization : alliance.source_organization + end +end diff --git a/app/models/organization.rb b/app/models/organization.rb index 90847ee3..56280e84 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -24,6 +24,8 @@ class Organization < ApplicationRecord has_many :inquiries has_many :documents, as: :documentable, dependent: :destroy has_many :petitions, dependent: :delete_all + has_many :initiated_alliances, class_name: "OrganizationAlliance", foreign_key: "source_organization_id", dependent: :destroy + has_many :received_alliances, class_name: "OrganizationAlliance", foreign_key: "target_organization_id", dependent: :destroy validates :name, presence: true, uniqueness: true @@ -52,15 +54,33 @@ def display_name_with_uid self end - # Returns the id to be displayed in the :new transfer page with the given - # destination_accountable - # - # @params destination_accountable [Organization | Object] target of a transfer - # @return [Integer | String] def display_id account.accountable_id end + def alliance_with(organization) + initiated_alliances.find_by(target_organization: organization) || + received_alliances.find_by(source_organization: organization) + end + + def pending_alliances + initiated_alliances.pending.or(received_alliances.pending) + end + + def accepted_alliances + initiated_alliances.accepted.or(received_alliances.accepted) + end + + def rejected_alliances + initiated_alliances.rejected.or(received_alliances.rejected) + end + + def allied_organizations + source_org_ids = initiated_alliances.accepted.pluck(:target_organization_id) + target_org_ids = received_alliances.accepted.pluck(:source_organization_id) + Organization.where(id: source_org_ids + target_org_ids) + end + def ensure_reg_number_seq! update_column(:reg_number_seq, members.maximum(:member_uid)) end diff --git a/app/models/organization_alliance.rb b/app/models/organization_alliance.rb new file mode 100644 index 00000000..6d242358 --- /dev/null +++ b/app/models/organization_alliance.rb @@ -0,0 +1,23 @@ +class OrganizationAlliance < ApplicationRecord + belongs_to :source_organization, class_name: "Organization" + belongs_to :target_organization, class_name: "Organization" + + enum status: { pending: 0, accepted: 1, rejected: 2 } + + validates :source_organization_id, presence: true + validates :target_organization_id, presence: true + validates :target_organization_id, uniqueness: { scope: :source_organization_id } + validate :cannot_ally_with_self + + scope :pending, -> { where(status: "pending") } + scope :accepted, -> { where(status: "accepted") } + scope :rejected, -> { where(status: "rejected") } + + private + + def cannot_ally_with_self + if source_organization_id == target_organization_id + errors.add(:base, "Cannot create an alliance with yourself") + end + end +end diff --git a/app/views/application/menus/_organization_listings_menu.html.erb b/app/views/application/menus/_organization_listings_menu.html.erb index aeb701c5..20f246ca 100644 --- a/app/views/application/menus/_organization_listings_menu.html.erb +++ b/app/views/application/menus/_organization_listings_menu.html.erb @@ -16,6 +16,12 @@ <%= t('petitions.applications') %> <% end %> +
  • + <%= link_to organization_alliances_path, class: "dropdown-item" do %> + <%= glyph :globe %> + <%= t "application.navbar.organization_alliances" %> + <% end %> +
  • <%= link_to offers_path(org: current_organization), class: "dropdown-item" do %> <%= glyph :link %> diff --git a/app/views/organization_alliances/index.html.erb b/app/views/organization_alliances/index.html.erb new file mode 100644 index 00000000..6d4d722e --- /dev/null +++ b/app/views/organization_alliances/index.html.erb @@ -0,0 +1,97 @@ +

    <%= t('organization_alliances.title') %>

    + +
    +
    + +
    +
    + +
    +
    +
    +
    + + + + + + + + <% if @status != 'rejected' %> + + <% end %> + + + + <% @alliances.each do |alliance| %> + <% other_org = alliance_recipient(alliance) %> + + + + + + <% if @status == 'pending' %> + + <% elsif @status == 'accepted' %> + + <% end %> + + <% end %> + +
    <%= t('organization_alliances.organization') %><%= t('organization_alliances.city') %><%= t('organization_alliances.members') %><%= t('organization_alliances.type') %><%= t('organization_alliances.actions') %>
    <%= link_to other_org.name, other_org %><%= other_org.city %><%= other_org.members.count %> + <%= t("organization_alliances.#{alliance_initiator?(alliance) ? 'sent' : 'received'}") %> + + <% if alliance_initiator?(alliance) %> + <%= link_to t('organization_alliances.cancel_request'), + organization_alliance_path(alliance), + method: :delete, + class: 'btn btn-danger', + data: { confirm: t('organization_alliances.confirm_cancel') } %> + <% else %> +
    + <%= link_to t('organization_alliances.accept'), + organization_alliance_path(alliance, status: 'accepted'), + method: :put, + class: 'btn btn-primary' %> + <%= link_to t('organization_alliances.reject'), + organization_alliance_path(alliance, status: 'rejected'), + method: :put, + class: 'btn btn-danger' %> +
    + <% end %> +
    + <%= link_to t('organization_alliances.end_alliance'), + organization_alliance_path(alliance), + method: :delete, + class: 'btn btn-danger', + data: { confirm: t('organization_alliances.confirm_end') } %> +
    +
    +
    +
    +
    diff --git a/app/views/organizations/_alliance_button.html.erb b/app/views/organizations/_alliance_button.html.erb new file mode 100644 index 00000000..6be3d44e --- /dev/null +++ b/app/views/organizations/_alliance_button.html.erb @@ -0,0 +1,16 @@ +<% if current_user&.manages?(current_organization) && organization != current_organization %> + <% alliance = current_organization.alliance_with(organization) %> + <% if alliance.nil? %> + <%= link_to t('organization_alliances.request_alliance'), + organization_alliances_path(organization_alliance: { target_organization_id: organization.id }), + method: :post, + class: 'btn btn-secondary', + aria: { label: t('organization_alliances.request_alliance_for', org: organization.name) } %> + <% elsif alliance.pending? %> + <%= t('organization_alliances.pending') %> + <% elsif alliance.accepted? %> + <%= t('organization_alliances.active') %> + <% elsif alliance.rejected? %> + <%= t('organization_alliances.rejected') %> + <% end %> +<% end %> diff --git a/app/views/organizations/_organizations_row.html.erb b/app/views/organizations/_organizations_row.html.erb index f68caa55..30b5a6c5 100644 --- a/app/views/organizations/_organizations_row.html.erb +++ b/app/views/organizations/_organizations_row.html.erb @@ -7,4 +7,9 @@ <%= render "organizations/petition_button", organization: org %> + <% if current_user&.manages?(current_organization) %> + + <%= render "organizations/alliance_button", organization: org %> + + <% end %> diff --git a/app/views/organizations/index.html.erb b/app/views/organizations/index.html.erb index a6954fa5..f750148d 100644 --- a/app/views/organizations/index.html.erb +++ b/app/views/organizations/index.html.erb @@ -25,7 +25,10 @@ <%= t '.neighborhood' %> <%= t '.web' %> <%= t '.member_count' %> - + <%= t '.membership' %> + <% if current_user&.manages?(current_organization) %> + <%= t '.alliance' %> + <% end %> diff --git a/app/views/shared/_post_filters.html.erb b/app/views/shared/_post_filters.html.erb index d471cb0c..4cdf22db 100644 --- a/app/views/shared/_post_filters.html.erb +++ b/app/views/shared/_post_filters.html.erb @@ -1,4 +1,5 @@ <% @category = Category.find_by(id: params[:cat]) %> +<% selected_org = Organization.find_by(id: params[:org]) %>
  • + diff --git a/config/locales/en.yml b/config/locales/en.yml index 36c5f5f5..4a3c7e2d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -141,6 +141,7 @@ en: last_login: Last login offer_public_link: Offers public link organizations: Organizations + organization_alliances: Organizations reports: Reports sign_out: Logout statistics: Statistics @@ -370,6 +371,35 @@ en: show: give_time_for: Time transfer for this offer offered_by: Offered by + organization_alliances: + title: "Organization Alliances" + created: "Alliance request sent" + updated: "Alliance status updated" + destroyed: "Alliance has been ended" + error_destroying: "Could not end alliance" + not_authorized: "You are not authorized to manage alliances" + organization: "Organization" + city: "City" + members: "Members" + type: "Type" + actions: "Actions" + sent: "Sent" + received: "Received" + pending: "Pending" + active: "Active" + rejected: "Rejected" + request_alliance: "Request alliance" + cancel_request: "Cancel request" + accept: "Accept" + reject: "Reject" + end_alliance: "End alliance" + confirm_cancel: "Are you sure you want to cancel this alliance request?" + confirm_end: "Are you sure you want to end this alliance?" + search_organizations: "Search organizations" + status: + pending: "Pending Requests" + accepted: "Active Alliances" + rejected: "Rejected Requests" organization_notifier: member_deleted: body: User %{username} has unsubscribed from the organization. @@ -382,6 +412,8 @@ en: give_time: Give time to index: member_count: Number of users + membership: "Membership" + alliance: "Alliance" new: new: New bank show: diff --git a/config/locales/es.yml b/config/locales/es.yml index 50ee7b64..5de719c6 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -141,6 +141,7 @@ es: last_login: Último login offer_public_link: Enlace público a ofertas organizations: Organizaciones + organization_alliances: Organizaciones reports: Informes sign_out: Desconectar statistics: Estadísticas @@ -370,6 +371,35 @@ es: show: give_time_for: Transferir tiempo por esta oferta offered_by: Ofertantes + organization_alliances: + title: "Alianzas" + created: "Solicitud de alianza enviada" + updated: "Estado de alianza actualizado" + destroyed: "La alianza ha finalizado" + error_destroying: "No se pudo finalizar la alianza" + not_authorized: "No estás autorizado para gestionar alianzas" + organization: "Organización" + city: "Ciudad" + members: "Miembros" + type: "Tipo" + actions: "Acciones" + sent: "Enviadas" + received: "Recibidas" + pending: "Pendientes" + active: "Activa" + rejected: "Rechazada" + request_alliance: "Solicitar alianza" + cancel_request: "Cancelar solicitud" + accept: "Aceptar" + reject: "Rechazar" + end_alliance: "Finalizar alianza" + confirm_cancel: "¿Estás seguro de que quieres cancelar esta solicitud de alianza?" + confirm_end: "¿Estás seguro de que quieres finalizar esta alianza?" + search_organizations: "Buscar organizaciones" + status: + pending: "Solicitudes Pendientes" + accepted: "Alianzas Activas" + rejected: "Solicitudes Rechazadas" organization_notifier: member_deleted: body: El usuario %{username} se ha dado de baja de la organización. @@ -382,6 +412,8 @@ es: give_time: Dar Tiempo a index: member_count: Número de usuarios + membership: "Membresía" + alliance: "Alianza" new: new: Nuevo banco show: diff --git a/config/routes.rb b/config/routes.rb index 500c4581..2eba7fd1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,6 +35,8 @@ end get :select_organization, to: 'organizations#select_organization' + resources :organization_alliances, only: [:index, :create, :update, :destroy] + resources :users, concerns: :accountable, except: :destroy, :path => "members" do collection do get 'signup' diff --git a/db/migrate/20250412110249_create_organization_alliances.rb b/db/migrate/20250412110249_create_organization_alliances.rb new file mode 100644 index 00000000..320a702f --- /dev/null +++ b/db/migrate/20250412110249_create_organization_alliances.rb @@ -0,0 +1,14 @@ +class CreateOrganizationAlliances < ActiveRecord::Migration[7.2] + def change + create_table :organization_alliances do |t| + t.references :source_organization, foreign_key: { to_table: :organizations } + t.references :target_organization, foreign_key: { to_table: :organizations } + t.integer :status, default: 0 + + t.timestamps + end + + add_index :organization_alliances, [:source_organization_id, :target_organization_id], + unique: true, name: 'index_org_alliances_on_source_and_target' + end +end \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index b51e5fae..4388a527 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -479,6 +479,39 @@ CREATE SEQUENCE public.movements_id_seq ALTER SEQUENCE public.movements_id_seq OWNED BY public.movements.id; +-- +-- Name: organization_alliances; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.organization_alliances ( + id bigint NOT NULL, + source_organization_id bigint, + target_organization_id bigint, + status integer DEFAULT 0, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: organization_alliances_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.organization_alliances_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: organization_alliances_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.organization_alliances_id_seq OWNED BY public.organization_alliances.id; + + -- -- Name: organizations; Type: TABLE; Schema: public; Owner: - -- @@ -818,6 +851,13 @@ ALTER TABLE ONLY public.members ALTER COLUMN id SET DEFAULT nextval('public.memb ALTER TABLE ONLY public.movements ALTER COLUMN id SET DEFAULT nextval('public.movements_id_seq'::regclass); +-- +-- Name: organization_alliances id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organization_alliances ALTER COLUMN id SET DEFAULT nextval('public.organization_alliances_id_seq'::regclass); + + -- -- Name: organizations id; Type: DEFAULT; Schema: public; Owner: - -- @@ -956,6 +996,14 @@ ALTER TABLE ONLY public.movements ADD CONSTRAINT movements_pkey PRIMARY KEY (id); +-- +-- Name: organization_alliances organization_alliances_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organization_alliances + ADD CONSTRAINT organization_alliances_pkey PRIMARY KEY (id); + + -- -- Name: organizations organizations_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -1144,6 +1192,27 @@ CREATE INDEX index_movements_on_account_id ON public.movements USING btree (acco CREATE INDEX index_movements_on_transfer_id ON public.movements USING btree (transfer_id); +-- +-- Name: index_org_alliances_on_source_and_target; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_org_alliances_on_source_and_target ON public.organization_alliances USING btree (source_organization_id, target_organization_id); + + +-- +-- Name: index_organization_alliances_on_source_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_organization_alliances_on_source_organization_id ON public.organization_alliances USING btree (source_organization_id); + + +-- +-- Name: index_organization_alliances_on_target_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_organization_alliances_on_target_organization_id ON public.organization_alliances USING btree (target_organization_id); + + -- -- Name: index_organizations_on_name; Type: INDEX; Schema: public; Owner: - -- @@ -1299,6 +1368,14 @@ ALTER TABLE ONLY public.push_notifications ADD CONSTRAINT fk_rails_79a395b2d7 FOREIGN KEY (event_id) REFERENCES public.events(id); +-- +-- Name: organization_alliances fk_rails_7c459bc8e7; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organization_alliances + ADD CONSTRAINT fk_rails_7c459bc8e7 FOREIGN KEY (source_organization_id) REFERENCES public.organizations(id); + + -- -- Name: active_storage_variant_records fk_rails_993965df05; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -1315,6 +1392,14 @@ ALTER TABLE ONLY public.active_storage_attachments ADD CONSTRAINT fk_rails_c3b3935057 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id); +-- +-- Name: organization_alliances fk_rails_da452c7bdc; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organization_alliances + ADD CONSTRAINT fk_rails_da452c7bdc FOREIGN KEY (target_organization_id) REFERENCES public.organizations(id); + + -- -- PostgreSQL database dump complete -- @@ -1395,4 +1480,5 @@ INSERT INTO "schema_migrations" (version) VALUES ('20241230170753'), ('20250215163404'), ('20250215163405'), -('20250215163406'); +('20250215163406'), +('20250412110249'); diff --git a/spec/controllers/organization_alliances_controller_spec.rb b/spec/controllers/organization_alliances_controller_spec.rb new file mode 100644 index 00000000..58ff0b40 --- /dev/null +++ b/spec/controllers/organization_alliances_controller_spec.rb @@ -0,0 +1,155 @@ +RSpec.describe OrganizationAlliancesController do + let(:organization) { Fabricate(:organization) } + let(:other_organization) { Fabricate(:organization) } + let(:member) { Fabricate(:member, organization: organization, manager: true) } + let(:user) { member.user } + + before do + login(user) + end + + describe "GET #index" do + let!(:pending_sent) { + OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization, + status: "pending" + ) + } + + let!(:pending_received) { + OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: organization, + status: "pending" + ) + } + + let!(:accepted) { + OrganizationAlliance.create!( + source_organization: organization, + target_organization: Fabricate(:organization), + status: "accepted" + ) + } + + let!(:rejected) { + OrganizationAlliance.create!( + source_organization: organization, + target_organization: Fabricate(:organization), + status: "rejected" + ) + } + + it "lists pending alliances by default" do + get :index + + expect(assigns(:status)).to eq("pending") + expect(assigns(:alliances)).to include(pending_sent, pending_received) + expect(assigns(:alliances)).not_to include(accepted, rejected) + end + + it "lists accepted alliances when status is accepted" do + get :index, params: { status: "accepted" } + + expect(assigns(:status)).to eq("accepted") + expect(assigns(:alliances)).to include(accepted) + expect(assigns(:alliances)).not_to include(pending_sent, pending_received, rejected) + end + + it "lists rejected alliances when status is rejected" do + get :index, params: { status: "rejected" } + + expect(assigns(:status)).to eq("rejected") + expect(assigns(:alliances)).to include(rejected) + expect(assigns(:alliances)).not_to include(pending_sent, pending_received, accepted) + end + end + + describe "POST #create" do + it "creates a new alliance" do + expect { + post :create, params: { organization_alliance: { target_organization_id: other_organization.id } } + }.to change(OrganizationAlliance, :count).by(1) + + expect(flash[:notice]).to eq(I18n.t("organization_alliances.created")) + expect(response).to redirect_to(organizations_path) + end + + it "sets flash error if alliance cannot be created" do + # Try to create alliance with self which is invalid + allow_any_instance_of(OrganizationAlliance).to receive(:save).and_return(false) + allow_any_instance_of(OrganizationAlliance).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return("Error message") + + post :create, params: { organization_alliance: { target_organization_id: organization.id } } + + expect(flash[:error]).to eq("Error message") + expect(response).to redirect_to(organizations_path) + end + end + + describe "PUT #update" do + let!(:alliance) { + OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: organization, + status: "pending" + ) + } + + it "updates alliance status to accepted" do + put :update, params: { id: alliance.id, status: "accepted" } + + alliance.reload + expect(alliance).to be_accepted + expect(flash[:notice]).to eq(I18n.t("organization_alliances.updated")) + expect(response).to redirect_to(organization_alliances_path) + end + + it "updates alliance status to rejected" do + put :update, params: { id: alliance.id, status: "rejected" } + + alliance.reload + expect(alliance).to be_rejected + expect(flash[:notice]).to eq(I18n.t("organization_alliances.updated")) + expect(response).to redirect_to(organization_alliances_path) + end + + it "sets flash error if alliance cannot be updated" do + allow_any_instance_of(OrganizationAlliance).to receive(:update).and_return(false) + allow_any_instance_of(OrganizationAlliance).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return("Error message") + + put :update, params: { id: alliance.id, status: "accepted" } + + expect(flash[:error]).to eq("Error message") + expect(response).to redirect_to(organization_alliances_path) + end + end + + describe "DELETE #destroy" do + let!(:alliance) { + OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization + ) + } + + it "destroys the alliance" do + expect { + delete :destroy, params: { id: alliance.id } + }.to change(OrganizationAlliance, :count).by(-1) + + expect(flash[:notice]).to eq(I18n.t("organization_alliances.destroyed")) + expect(response).to redirect_to(organization_alliances_path) + end + + it "sets flash error if alliance cannot be destroyed" do + allow_any_instance_of(OrganizationAlliance).to receive(:destroy).and_return(false) + + delete :destroy, params: { id: alliance.id } + + expect(flash[:error]).to eq(I18n.t("organization_alliances.error_destroying")) + expect(response).to redirect_to(organization_alliances_path) + end + end +end diff --git a/spec/models/organization_alliance_spec.rb b/spec/models/organization_alliance_spec.rb new file mode 100644 index 00000000..eb4da19e --- /dev/null +++ b/spec/models/organization_alliance_spec.rb @@ -0,0 +1,121 @@ +RSpec.describe OrganizationAlliance do + let(:organization) { Fabricate(:organization) } + let(:other_organization) { Fabricate(:organization) } + + around do |example| + I18n.with_locale(:en) do + example.run + end + end + + describe "validations" do + it "is valid with valid attributes" do + alliance = OrganizationAlliance.new( + source_organization: organization, + target_organization: other_organization + ) + expect(alliance).to be_valid + end + + it "is not valid without a source organization" do + alliance = OrganizationAlliance.new( + source_organization: nil, + target_organization: other_organization + ) + expect(alliance).not_to be_valid + expect(alliance.errors[:source_organization_id]).to include("can't be blank") + end + + it "is not valid without a target organization" do + alliance = OrganizationAlliance.new( + source_organization: organization, + target_organization: nil + ) + expect(alliance).not_to be_valid + expect(alliance.errors[:target_organization_id]).to include("can't be blank") + end + + it "is not valid if creating an alliance with self" do + alliance = OrganizationAlliance.new( + source_organization: organization, + target_organization: organization + ) + expect(alliance).not_to be_valid + expect(alliance.errors[:base]).to include("Cannot create an alliance with yourself") + end + + it "is not valid if alliance already exists" do + OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization + ) + + alliance = OrganizationAlliance.new( + source_organization: organization, + target_organization: other_organization + ) + expect(alliance).not_to be_valid + expect(alliance.errors[:target_organization_id]).to include("has already been taken") + end + end + + describe "status enum" do + let(:alliance) { + OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization + ) + } + + it "defaults to pending" do + expect(alliance).to be_pending + end + + it "can be set to accepted" do + alliance.accepted! + expect(alliance).to be_accepted + end + + it "can be set to rejected" do + alliance.rejected! + expect(alliance).to be_rejected + end + end + + describe "scopes" do + before do + @pending_alliance = OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization, + status: "pending" + ) + + @accepted_alliance = OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: Fabricate(:organization), + status: "accepted" + ) + + @rejected_alliance = OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: Fabricate(:organization), + status: "rejected" + ) + end + + it "returns pending alliances" do + expect(OrganizationAlliance.pending).to include(@pending_alliance) + expect(OrganizationAlliance.pending).not_to include(@accepted_alliance, @rejected_alliance) + end + + it "returns accepted alliances" do + expect(OrganizationAlliance.accepted).to include(@accepted_alliance) + expect(OrganizationAlliance.accepted).not_to include(@pending_alliance, @rejected_alliance) + end + + it "returns rejected alliances" do + expect(OrganizationAlliance.rejected).to include(@rejected_alliance) + expect(OrganizationAlliance.rejected).not_to include(@pending_alliance, @accepted_alliance) + end + end +end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 557c48ea..dfbbd0c3 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -5,24 +5,20 @@ it "validates content_type" do temp_file = Tempfile.new('test.txt') organization.logo.attach(io: File.open(temp_file.path), filename: 'test.txt') - expect(organization).to be_invalid temp_file = Tempfile.new('test.svg') organization.logo.attach(io: File.open(temp_file.path), filename: 'test.svg') - expect(organization).to be_invalid temp_file = Tempfile.new('test.png') organization.logo.attach(io: File.open(temp_file.path), filename: 'test.png') - expect(organization).to be_valid end end describe '#display_id' do subject { organization.display_id } - it { is_expected.to eq(organization.account.accountable_id) } end @@ -70,4 +66,96 @@ organization.save expect(organization.errors[:name]).to include(I18n.t('errors.messages.blank')) end + + describe "alliance methods" do + let(:organization) { Fabricate(:organization) } + let(:other_organization) { Fabricate(:organization) } + + describe "#alliance_with" do + it "returns nil if no alliance exists" do + expect(organization.alliance_with(other_organization)).to be_nil + end + + it "returns alliance when organization is source" do + alliance = OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization + ) + + expect(organization.alliance_with(other_organization)).to eq(alliance) + end + + it "returns alliance when organization is target" do + alliance = OrganizationAlliance.create!( + source_organization: other_organization, + target_organization: organization + ) + + expect(organization.alliance_with(other_organization)).to eq(alliance) + end + end + + describe "alliance status methods" do + let(:third_organization) { Fabricate(:organization) } + + before do + @pending_sent = OrganizationAlliance.create!( + source_organization: organization, + target_organization: other_organization, + status: "pending" + ) + + @pending_received = OrganizationAlliance.create!( + source_organization: third_organization, + target_organization: organization, + status: "pending" + ) + + @accepted_sent = OrganizationAlliance.create!( + source_organization: organization, + target_organization: Fabricate(:organization), + status: "accepted" + ) + + @accepted_received = OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: organization, + status: "accepted" + ) + + @rejected_sent = OrganizationAlliance.create!( + source_organization: organization, + target_organization: Fabricate(:organization), + status: "rejected" + ) + + @rejected_received = OrganizationAlliance.create!( + source_organization: Fabricate(:organization), + target_organization: organization, + status: "rejected" + ) + end + + it "returns pending alliances" do + expect(organization.pending_alliances).to include(@pending_sent, @pending_received) + expect(organization.pending_alliances).not_to include( + @accepted_sent, @accepted_received, @rejected_sent, @rejected_received + ) + end + + it "returns accepted alliances" do + expect(organization.accepted_alliances).to include(@accepted_sent, @accepted_received) + expect(organization.accepted_alliances).not_to include( + @pending_sent, @pending_received, @rejected_sent, @rejected_received + ) + end + + it "returns rejected alliances" do + expect(organization.rejected_alliances).to include(@rejected_sent, @rejected_received) + expect(organization.rejected_alliances).not_to include( + @pending_sent, @pending_received, @accepted_sent, @accepted_received + ) + end + end + end end