From 5597cab95c266b2ac6c6f34e97d87034d5822c1b Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Mon, 5 Jan 2026 02:39:32 -0800 Subject: [PATCH] Party sharing into crews (#207) * add party_shares table and model with associations * add party share errors and blueprint * add party shares controller and routes * include shared parties in listings and show * add party share factory and model specs * add party shares controller specs * include shares in party response for owners * add crew shared_parties endpoint --- app/blueprints/api/v1/party_blueprint.rb | 15 ++ .../api/v1/party_share_blueprint.rb | 57 +++++++ app/controllers/api/v1/api_controller.rb | 5 + app/controllers/api/v1/crews_controller.rb | 20 ++- app/controllers/api/v1/parties_controller.rb | 8 +- .../api/v1/party_shares_controller.rb | 67 +++++++++ app/errors/party_share_errors.rb | 77 ++++++++++ app/models/crew.rb | 2 + app/models/party.rb | 34 +++++ app/models/party_share.rb | 63 ++++++++ app/models/user.rb | 1 + app/services/party_query_builder.rb | 19 ++- config/routes.rb | 6 + db/data_schema.rb | 2 +- .../20260105053753_create_party_shares.rb | 21 +++ db/schema.rb | 19 ++- spec/factories/party_shares.rb | 17 +++ spec/models/party_share_spec.rb | 121 +++++++++++++++ spec/requests/api/v1/crews_spec.rb | 55 +++++++ spec/requests/api/v1/party_shares_spec.rb | 139 ++++++++++++++++++ 20 files changed, 739 insertions(+), 9 deletions(-) create mode 100644 app/blueprints/api/v1/party_share_blueprint.rb create mode 100644 app/controllers/api/v1/party_shares_controller.rb create mode 100644 app/errors/party_share_errors.rb create mode 100644 app/models/party_share.rb create mode 100644 db/migrate/20260105053753_create_party_shares.rb create mode 100644 spec/factories/party_shares.rb create mode 100644 spec/models/party_share_spec.rb create mode 100644 spec/requests/api/v1/party_shares_spec.rb diff --git a/app/blueprints/api/v1/party_blueprint.rb b/app/blueprints/api/v1/party_blueprint.rb index 395b699..2e8df6b 100644 --- a/app/blueprints/api/v1/party_blueprint.rb +++ b/app/blueprints/api/v1/party_blueprint.rb @@ -52,6 +52,21 @@ module Api include_view :nested_objects # Characters, Weapons, Summons include_view :remix_metadata # Remixes, Source party include_view :job_metadata # Accessory, Skills, Guidebooks + + # Shares (only visible to owner) + field :shares, if: ->(_field_name, party, options) { + options[:current_user] && party.user_id == options[:current_user].id + } do |party| + party.party_shares.includes(:shareable).map do |share| + { + id: share.id, + shareable_type: share.shareable_type.downcase, + shareable_id: share.shareable_id, + shareable_name: share.shareable.try(:name), + created_at: share.created_at + } + end + end end # Primary object associations diff --git a/app/blueprints/api/v1/party_share_blueprint.rb b/app/blueprints/api/v1/party_share_blueprint.rb new file mode 100644 index 0000000..4bbc73d --- /dev/null +++ b/app/blueprints/api/v1/party_share_blueprint.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Api + module V1 + class PartyShareBlueprint < ApiBlueprint + identifier :id + + fields :created_at + + field :shareable_type do |share| + share.shareable_type.downcase + end + + field :shareable_id do |share| + share.shareable_id + end + + view :with_shareable do + fields :created_at + + field :shareable_type do |share| + share.shareable_type.downcase + end + + field :shareable do |share| + case share.shareable_type + when 'Crew' + CrewBlueprint.render_as_hash(share.shareable, view: :minimal) + end + end + + field :shared_by do |share| + UserBlueprint.render_as_hash(share.shared_by, view: :minimal) + end + end + + view :with_party do + fields :created_at + + field :shareable_type do |share| + share.shareable_type.downcase + end + + field :party do |share| + PartyBlueprint.render_as_hash(share.party, view: :preview) + end + + field :shareable do |share| + case share.shareable_type + when 'Crew' + CrewBlueprint.render_as_hash(share.shareable, view: :minimal) + end + end + end + end + end +end diff --git a/app/controllers/api/v1/api_controller.rb b/app/controllers/api/v1/api_controller.rb index 1efc0bf..e57a07b 100644 --- a/app/controllers/api/v1/api_controller.rb +++ b/app/controllers/api/v1/api_controller.rb @@ -44,6 +44,11 @@ module Api render json: e.to_hash, status: e.http_status end + # Party share errors + rescue_from PartyShareErrors::PartyShareError do |e| + render json: e.to_hash, status: e.http_status + end + rescue_from GranblueError do |e| render_error(e) end diff --git a/app/controllers/api/v1/crews_controller.rb b/app/controllers/api/v1/crews_controller.rb index 7879063..89ac110 100644 --- a/app/controllers/api/v1/crews_controller.rb +++ b/app/controllers/api/v1/crews_controller.rb @@ -6,9 +6,9 @@ module Api include CrewAuthorizationConcern before_action :restrict_access - before_action :set_crew, only: %i[show update members roster leave transfer_captain] - before_action :require_crew!, only: %i[show update members roster] - before_action :authorize_crew_member!, only: %i[show members] + before_action :set_crew, only: %i[show update members roster leave transfer_captain shared_parties] + before_action :require_crew!, only: %i[show update members roster shared_parties] + before_action :authorize_crew_member!, only: %i[show members shared_parties] before_action :authorize_crew_officer!, only: %i[update roster] before_action :authorize_crew_captain!, only: %i[transfer_captain] @@ -106,6 +106,20 @@ module Api render json: CrewBlueprint.render(@crew.reload, view: :full, root: :crew, current_user: current_user) end + # GET /crew/shared_parties + # Returns parties that have been shared with this crew + def shared_parties + parties = @crew.shared_parties + .includes(:user, :job, :raid) + .order(created_at: :desc) + .paginate(page: params[:page], per_page: page_size) + + render json: { + parties: PartyBlueprint.render_as_hash(parties, view: :preview, current_user: current_user), + meta: pagination_meta(parties) + } + end + private def set_crew diff --git a/app/controllers/api/v1/parties_controller.rb b/app/controllers/api/v1/parties_controller.rb index 229e772..d9fa825 100644 --- a/app/controllers/api/v1/parties_controller.rb +++ b/app/controllers/api/v1/parties_controller.rb @@ -56,11 +56,15 @@ module Api end # Shows a specific party. + # Uses viewable_by? to check visibility including crew sharing. + # Also allows access via edit_key for anonymous parties. def show - return render_unauthorized_response if @party.private? && (!current_user || not_owner?) + unless @party.viewable_by?(current_user) || !not_owner? + return render_unauthorized_response + end if @party - render json: PartyBlueprint.render(@party, view: :full, root: :party) + render json: PartyBlueprint.render(@party, view: :full, root: :party, current_user: current_user) else render_not_found_response('project') end diff --git a/app/controllers/api/v1/party_shares_controller.rb b/app/controllers/api/v1/party_shares_controller.rb new file mode 100644 index 0000000..915c765 --- /dev/null +++ b/app/controllers/api/v1/party_shares_controller.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Api + module V1 + class PartySharesController < Api::V1::ApiController + before_action :restrict_access + before_action :set_party + before_action :authorize_party_owner! + before_action :set_party_share, only: [:destroy] + + # GET /parties/:party_id/shares + # List all shares for a party (only for owner) + def index + shares = @party.party_shares.includes(:shareable, :shared_by) + render json: PartyShareBlueprint.render(shares, view: :with_shareable, root: :shares) + end + + # POST /parties/:party_id/shares + # Share a party with the current user's crew + def create + crew = current_user.crew + raise PartyShareErrors::NotInCrewError unless crew + + # For now, users can only share to their own crew + # Future: support party_share_params[:crew_id] for sharing to other crews + share = PartyShare.new( + party: @party, + shareable: crew, + shared_by: current_user + ) + + if share.save + render json: PartyShareBlueprint.render(share, view: :with_shareable, root: :share), status: :created + else + render_validation_error_response(share) + end + end + + # DELETE /parties/:party_id/shares/:id + # Remove a share + def destroy + @party_share.destroy! + head :no_content + end + + private + + def set_party + @party = Party.find(params[:party_id]) + end + + def set_party_share + @party_share = @party.party_shares.find(params[:id]) + end + + def authorize_party_owner! + return if @party.user_id == current_user.id + + raise Api::V1::UnauthorizedError + end + + def party_share_params + params.require(:share).permit(:crew_id) + end + end + end +end diff --git a/app/errors/party_share_errors.rb b/app/errors/party_share_errors.rb new file mode 100644 index 0000000..a2f591c --- /dev/null +++ b/app/errors/party_share_errors.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module PartyShareErrors + # Base class for all party share-related errors + class PartyShareError < StandardError + def http_status + :unprocessable_entity + end + + def code + self.class.name.demodulize.underscore + end + + def to_hash + { + message: message, + code: code + } + end + end + + class NotInCrewError < PartyShareError + def http_status + :unprocessable_entity + end + + def code + 'not_in_crew' + end + + def message + 'You must be in a crew to share parties' + end + end + + class NotPartyOwnerError < PartyShareError + def http_status + :forbidden + end + + def code + 'not_party_owner' + end + + def message + 'Only the party owner can share this party' + end + end + + class AlreadySharedError < PartyShareError + def http_status + :conflict + end + + def code + 'already_shared' + end + + def message + 'This party is already shared with this crew' + end + end + + class CanOnlyShareToOwnCrewError < PartyShareError + def http_status + :forbidden + end + + def code + 'can_only_share_to_own_crew' + end + + def message + 'You can only share parties with your own crew' + end + end +end diff --git a/app/models/crew.rb b/app/models/crew.rb index 915c14a..c59ceae 100644 --- a/app/models/crew.rb +++ b/app/models/crew.rb @@ -10,6 +10,8 @@ class Crew < ApplicationRecord has_many :crew_gw_participations, dependent: :destroy has_many :gw_events, through: :crew_gw_participations has_many :phantom_players, dependent: :destroy + has_many :party_shares, as: :shareable, dependent: :destroy + has_many :shared_parties, through: :party_shares, source: :party validates :name, presence: true, length: { maximum: 100 } validates :gamertag, length: { maximum: 50 }, allow_nil: true diff --git a/app/models/party.rb b/app/models/party.rb index 17780e6..bd55f36 100644 --- a/app/models/party.rb +++ b/app/models/party.rb @@ -156,6 +156,8 @@ class Party < ApplicationRecord inverse_of: :party has_many :favorites, dependent: :destroy + has_many :party_shares, dependent: :destroy + has_many :shared_crews, through: :party_shares, source: :shareable, source_type: 'Crew' accepts_nested_attributes_for :characters accepts_nested_attributes_for :summons @@ -261,6 +263,38 @@ class Party < ApplicationRecord visibility == 3 end + ## + # Checks if the party is shared with a specific crew. + # + # @param crew [Crew] the crew to check. + # @return [Boolean] true if shared with the crew; false otherwise. + def shared_with_crew?(crew) + return false unless crew + + party_shares.exists?(shareable_type: 'Crew', shareable_id: crew.id) + end + + ## + # Checks if a user can view this party based on visibility and sharing rules. + # A user can view if: + # - The party is public + # - The party is unlisted (accessible via direct link) + # - They own the party + # - They are an admin + # - The party is shared with a crew they belong to + # + # @param user [User, nil] the user to check. + # @return [Boolean] true if the user can view the party; false otherwise. + def viewable_by?(user) + return true if public? + return true if unlisted? + return true if user && user_id == user.id + return true if user&.admin? + return true if user&.crew && shared_with_crew?(user.crew) + + false + end + ## # Checks if the party is favorited by a given user. # diff --git a/app/models/party_share.rb b/app/models/party_share.rb new file mode 100644 index 0000000..ef735dc --- /dev/null +++ b/app/models/party_share.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +## +# PartyShare represents a sharing relationship between a party and a group (e.g., a crew). +# It allows party owners to share their parties with specific groups, granting view access +# to members of those groups without changing the party's base visibility. +# +# @!attribute [rw] party +# @return [Party] the party being shared. +# @!attribute [rw] shareable +# @return [Crew] the polymorphic group the party is shared with. +# @!attribute [rw] shared_by +# @return [User] the user who created this share. +class PartyShare < ApplicationRecord + # Associations + belongs_to :party + belongs_to :shareable, polymorphic: true + belongs_to :shared_by, class_name: 'User' + + # Validations + validates :party_id, uniqueness: { + scope: [:shareable_type, :shareable_id], + message: 'has already been shared with this group' + } + validate :owner_can_share + validate :sharer_is_member_of_shareable + + # Scopes + scope :for_crew, ->(crew) { where(shareable_type: 'Crew', shareable_id: crew.id) } + scope :for_crews, ->(crew_ids) { where(shareable_type: 'Crew', shareable_id: crew_ids) } + scope :for_party, ->(party) { where(party_id: party.id) } + + ## + # Returns the blueprint class for serialization. + # + # @return [Class] the PartyShareBlueprint class. + def blueprint + PartyShareBlueprint + end + + private + + ## + # Validates that only the party owner can share the party. + # + # @return [void] + def owner_can_share + return if party&.user_id == shared_by_id + + errors.add(:shared_by, 'must be the party owner') + end + + ## + # Validates that the sharer is a member of the group they're sharing to. + # + # @return [void] + def sharer_is_member_of_shareable + return unless shareable_type == 'Crew' + return if shareable&.active_memberships&.exists?(user_id: shared_by_id) + + errors.add(:shareable, 'you must be a member of this crew') + end +end diff --git a/app/models/user.rb b/app/models/user.rb index ac2c8aa..9f24428 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,6 +19,7 @@ class User < ApplicationRecord has_many :crew_invitations, dependent: :destroy has_many :pending_crew_invitations, -> { where(status: :pending) }, class_name: 'CrewInvitation' has_many :sent_crew_invitations, class_name: 'CrewInvitation', foreign_key: :invited_by_id, dependent: :nullify + has_many :party_shares, foreign_key: :shared_by_id, dependent: :destroy ##### ActiveRecord Validations validates :username, diff --git a/app/services/party_query_builder.rb b/app/services/party_query_builder.rb index 600ed9c..2b77f2c 100644 --- a/app/services/party_query_builder.rb +++ b/app/services/party_query_builder.rb @@ -61,15 +61,30 @@ class PartyQueryBuilder end # Applies privacy settings based on whether the current user is an admin. + # Also includes parties shared with the current user's crew. def apply_privacy_settings(query) # If the options say to skip privacy filtering (e.g. when viewing your own profile), # then return the query unchanged. return query if @options[:skip_privacy] - # Otherwise, if not admin, only show public parties. + # Admins can see everything return query if @current_user&.admin? - query.where('visibility = ?', 1) + # Build conditions for what the user can see: + # 1. Public parties (visibility = 1) + # 2. Parties shared with their crew (if they're in a crew) + if @current_user&.crew + # User is in a crew - include public parties OR parties shared with their crew + query.where(<<-SQL.squish, 1, 'Crew', @current_user.crew.id) + visibility = ? OR parties.id IN ( + SELECT party_id FROM party_shares + WHERE shareable_type = ? AND shareable_id = ? + ) + SQL + else + # User is not in a crew - only show public parties + query.where('visibility = ?', 1) + end end # Builds a hash of filtering conditions from the params. diff --git a/config/routes.rb b/config/routes.rb index 9cf0772..189b5cb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -70,6 +70,11 @@ Rails.application.routes.draw do post 'parties/:id/regenerate_preview', to: 'parties#regenerate_preview' post 'parties/:id/remix', to: 'parties#remix' + # Party shares + resources :parties, only: [] do + resources :shares, controller: 'party_shares', only: [:index, :create, :destroy] + end + put 'parties/:id/jobs', to: 'jobs#update_job' put 'parties/:id/job_skills', to: 'jobs#update_job_skills' delete 'parties/:id/job_skills', to: 'jobs#destroy_job_skill' @@ -180,6 +185,7 @@ Rails.application.routes.draw do member do get :members get :roster + get :shared_parties post :leave end end diff --git a/db/data_schema.rb b/db/data_schema.rb index bb71a21..bc95d29 100644 --- a/db/data_schema.rb +++ b/db/data_schema.rb @@ -1 +1 @@ -DataMigrate::Data.define(version: 20251230000002) +DataMigrate::Data.define(version: 20260104000002) diff --git a/db/migrate/20260105053753_create_party_shares.rb b/db/migrate/20260105053753_create_party_shares.rb new file mode 100644 index 0000000..a1de39a --- /dev/null +++ b/db/migrate/20260105053753_create_party_shares.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreatePartyShares < ActiveRecord::Migration[8.0] + def change + create_table :party_shares, id: :uuid do |t| + t.references :party, type: :uuid, null: false, foreign_key: true + t.references :shareable, type: :uuid, null: false, polymorphic: true + t.references :shared_by, type: :uuid, null: false, foreign_key: { to_table: :users } + + t.timestamps + end + + # Prevent duplicate shares of the same party to the same group + add_index :party_shares, [:party_id, :shareable_type, :shareable_id], + unique: true, + name: 'index_party_shares_unique_per_shareable' + + # Quick lookup of all parties shared with a specific group + add_index :party_shares, [:shareable_type, :shareable_id] + end +end diff --git a/db/schema.rb b/db/schema.rb index 9442424..7bcee92 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_12_30_000004) do +ActiveRecord::Schema[8.0].define(version: 2026_01_05_053753) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "pg_catalog.plpgsql" @@ -677,6 +677,20 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_30_000004) do t.index ["weapons_count", "characters_count", "summons_count"], name: "index_parties_on_counters" end + create_table "party_shares", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "party_id", null: false + t.string "shareable_type", null: false + t.uuid "shareable_id", null: false + t.uuid "shared_by_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["party_id", "shareable_type", "shareable_id"], name: "index_party_shares_unique_per_shareable", unique: true + t.index ["party_id"], name: "index_party_shares_on_party_id" + t.index ["shareable_type", "shareable_id"], name: "index_party_shares_on_shareable" + t.index ["shareable_type", "shareable_id"], name: "index_party_shares_on_shareable_type_and_shareable_id" + t.index ["shared_by_id"], name: "index_party_shares_on_shared_by_id" + end + create_table "pg_search_documents", force: :cascade do |t| t.text "content" t.string "granblue_id" @@ -1029,6 +1043,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_30_000004) do t.string "forged_from" t.uuid "forge_chain_id" t.integer "forge_order" + t.integer "max_exorcism_level" t.index ["forge_chain_id"], name: "index_weapons_on_forge_chain_id" t.index ["forged_from"], name: "index_weapons_on_forged_from" t.index ["gacha"], name: "index_weapons_on_gacha" @@ -1111,6 +1126,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_30_000004) do add_foreign_key "parties", "parties", column: "source_party_id" add_foreign_key "parties", "raids" add_foreign_key "parties", "users" + add_foreign_key "party_shares", "parties" + add_foreign_key "party_shares", "users", column: "shared_by_id" add_foreign_key "phantom_players", "crew_memberships", column: "claimed_from_membership_id" add_foreign_key "phantom_players", "crews" add_foreign_key "phantom_players", "users", column: "claimed_by_id" diff --git a/spec/factories/party_shares.rb b/spec/factories/party_shares.rb new file mode 100644 index 0000000..1c506ea --- /dev/null +++ b/spec/factories/party_shares.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :party_share do + party + association :shareable, factory: :crew + association :shared_by, factory: :user + + # Ensure the shared_by user owns the party and is in the crew + after(:build) do |party_share| + party_share.party.user = party_share.shared_by + unless party_share.shareable.crew_memberships.exists?(user: party_share.shared_by, retired: false) + create(:crew_membership, crew: party_share.shareable, user: party_share.shared_by) + end + end + end +end diff --git a/spec/models/party_share_spec.rb b/spec/models/party_share_spec.rb new file mode 100644 index 0000000..79f4de8 --- /dev/null +++ b/spec/models/party_share_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PartyShare, type: :model do + describe 'associations' do + it { should belong_to(:party) } + it { should belong_to(:shareable) } + it { should belong_to(:shared_by).class_name('User') } + end + + describe 'validations' do + let(:crew) { create(:crew) } + let(:user) { create(:user) } + let(:party) { create(:party, user: user) } + + before do + create(:crew_membership, crew: crew, user: user) + end + + it 'validates uniqueness of party scoped to shareable' do + create(:party_share, party: party, shareable: crew, shared_by: user) + + duplicate = build(:party_share, party: party, shareable: crew, shared_by: user) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:party_id]).to include('has already been shared with this group') + end + + it 'allows same party to be shared with different crews' do + crew2 = create(:crew) + create(:crew_membership, crew: crew2, user: user) + + share1 = create(:party_share, party: party, shareable: crew, shared_by: user) + share2 = build(:party_share, party: party, shareable: crew2, shared_by: user) + + expect(share2).to be_valid + end + end + + describe 'owner validation' do + let(:crew) { create(:crew) } + let(:owner) { create(:user) } + let(:other_user) { create(:user) } + let(:party) { create(:party, user: owner) } + + before do + create(:crew_membership, crew: crew, user: owner) + create(:crew_membership, crew: crew, user: other_user) + end + + it 'allows owner to share their party' do + share = build(:party_share, party: party, shareable: crew, shared_by: owner) + expect(share).to be_valid + end + + it 'prevents non-owner from sharing the party' do + share = build(:party_share, party: party, shareable: crew, shared_by: other_user) + expect(share).not_to be_valid + expect(share.errors[:shared_by]).to include('must be the party owner') + end + end + + describe 'crew membership validation' do + let(:crew) { create(:crew) } + let(:user) { create(:user) } + let(:party) { create(:party, user: user) } + + it 'allows sharing to a crew the user belongs to' do + create(:crew_membership, crew: crew, user: user) + share = build(:party_share, party: party, shareable: crew, shared_by: user) + expect(share).to be_valid + end + + it 'prevents sharing to a crew the user does not belong to' do + share = build(:party_share, party: party, shareable: crew, shared_by: user) + expect(share).not_to be_valid + expect(share.errors[:shareable]).to include('you must be a member of this crew') + end + + it 'prevents sharing if user has retired from crew' do + membership = create(:crew_membership, crew: crew, user: user) + membership.retire! + + share = build(:party_share, party: party, shareable: crew, shared_by: user) + expect(share).not_to be_valid + end + end + + describe 'scopes' do + let(:crew1) { create(:crew) } + let(:crew2) { create(:crew) } + let(:user) { create(:user) } + let(:party1) { create(:party, user: user) } + let(:party2) { create(:party, user: user) } + + before do + create(:crew_membership, crew: crew1, user: user) + create(:crew_membership, crew: crew2, user: user) + end + + describe '.for_crew' do + it 'returns shares for a specific crew' do + share1 = create(:party_share, party: party1, shareable: crew1, shared_by: user) + share2 = create(:party_share, party: party2, shareable: crew2, shared_by: user) + + expect(PartyShare.for_crew(crew1)).to include(share1) + expect(PartyShare.for_crew(crew1)).not_to include(share2) + end + end + + describe '.for_party' do + it 'returns shares for a specific party' do + share1 = create(:party_share, party: party1, shareable: crew1, shared_by: user) + share2 = create(:party_share, party: party2, shareable: crew1, shared_by: user) + + expect(PartyShare.for_party(party1)).to include(share1) + expect(PartyShare.for_party(party1)).not_to include(share2) + end + end + end +end diff --git a/spec/requests/api/v1/crews_spec.rb b/spec/requests/api/v1/crews_spec.rb index a21038c..7cbcdc5 100644 --- a/spec/requests/api/v1/crews_spec.rb +++ b/spec/requests/api/v1/crews_spec.rb @@ -261,4 +261,59 @@ RSpec.describe 'Api::V1::Crews', type: :request do end end end + + describe 'GET /api/v1/crew/shared_parties' do + let(:crew) { create(:crew) } + let!(:membership) { create(:crew_membership, crew: crew, user: user, role: :member) } + + context 'as crew member' do + it 'returns parties shared with the crew' do + other_user = create(:user) + create(:crew_membership, crew: crew, user: other_user) + party = create(:party, user: other_user, visibility: 3) # private + create(:party_share, party: party, shareable: crew, shared_by: other_user) + + get '/api/v1/crew/shared_parties', headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['parties'].length).to eq(1) + expect(json['parties'][0]['id']).to eq(party.id) + end + + it 'returns empty array when no shared parties' do + get '/api/v1/crew/shared_parties', headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['parties']).to eq([]) + end + + it 'includes pagination meta' do + get '/api/v1/crew/shared_parties', headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['meta']).to include('count', 'total_pages', 'per_page') + end + end + + context 'when not in a crew' do + before { membership.retire! } + + it 'returns not found' do + get '/api/v1/crew/shared_parties', headers: auth_headers + + expect(response).to have_http_status(:not_found) + end + end + + context 'without authentication' do + it 'returns unauthorized' do + get '/api/v1/crew/shared_parties' + + expect(response).to have_http_status(:unauthorized) + end + end + end end diff --git a/spec/requests/api/v1/party_shares_spec.rb b/spec/requests/api/v1/party_shares_spec.rb new file mode 100644 index 0000000..64ba752 --- /dev/null +++ b/spec/requests/api/v1/party_shares_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::V1::PartyShares', type: :request do + let(:user) { create(:user) } + let(:access_token) do + Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public') + end + let(:auth_headers) { { 'Authorization' => "Bearer #{access_token.token}" } } + + let(:crew) { create(:crew) } + let(:party) { create(:party, user: user) } + + before do + create(:crew_membership, crew: crew, user: user) + end + + describe 'GET /api/v1/parties/:party_id/shares' do + context 'as party owner' do + it 'returns list of shares' do + share = create(:party_share, party: party, shareable: crew, shared_by: user) + + get "/api/v1/parties/#{party.id}/shares", headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['shares'].length).to eq(1) + expect(json['shares'][0]['id']).to eq(share.id) + end + + it 'returns empty array when no shares' do + get "/api/v1/parties/#{party.id}/shares", headers: auth_headers + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['shares']).to eq([]) + end + end + + context 'as non-owner' do + let(:other_user) { create(:user) } + let(:other_token) do + Doorkeeper::AccessToken.create!(resource_owner_id: other_user.id, expires_in: 30.days, scopes: 'public') + end + let(:other_headers) { { 'Authorization' => "Bearer #{other_token.token}" } } + + it 'returns unauthorized' do + get "/api/v1/parties/#{party.id}/shares", headers: other_headers + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'without authentication' do + it 'returns unauthorized' do + get "/api/v1/parties/#{party.id}/shares" + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/parties/:party_id/shares' do + context 'as party owner in a crew' do + it 'shares the party with their crew' do + post "/api/v1/parties/#{party.id}/shares", headers: auth_headers + + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + expect(json['share']['shareable_type']).to eq('crew') + expect(json['share']['shareable']['id']).to eq(crew.id) + end + + it 'returns error when already shared' do + create(:party_share, party: party, shareable: crew, shared_by: user) + + post "/api/v1/parties/#{party.id}/shares", headers: auth_headers + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as party owner not in a crew' do + before do + user.active_crew_membership.retire! + end + + it 'returns not_in_crew error' do + post "/api/v1/parties/#{party.id}/shares", headers: auth_headers + + expect(response).to have_http_status(:unprocessable_entity) + json = JSON.parse(response.body) + expect(json['code']).to eq('not_in_crew') + end + end + + context 'as non-owner' do + let(:other_user) { create(:user) } + let(:other_token) do + Doorkeeper::AccessToken.create!(resource_owner_id: other_user.id, expires_in: 30.days, scopes: 'public') + end + let(:other_headers) { { 'Authorization' => "Bearer #{other_token.token}" } } + + it 'returns unauthorized' do + post "/api/v1/parties/#{party.id}/shares", headers: other_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE /api/v1/parties/:party_id/shares/:id' do + let!(:share) { create(:party_share, party: party, shareable: crew, shared_by: user) } + + context 'as party owner' do + it 'removes the share' do + delete "/api/v1/parties/#{party.id}/shares/#{share.id}", headers: auth_headers + + expect(response).to have_http_status(:no_content) + expect(PartyShare.exists?(share.id)).to be false + end + end + + context 'as non-owner' do + let(:other_user) { create(:user) } + let(:other_token) do + Doorkeeper::AccessToken.create!(resource_owner_id: other_user.id, expires_in: 30.days, scopes: 'public') + end + let(:other_headers) { { 'Authorization' => "Bearer #{other_token.token}" } } + + it 'returns unauthorized' do + delete "/api/v1/parties/#{party.id}/shares/#{share.id}", headers: other_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end +end