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
This commit is contained in:
parent
c3d9efa349
commit
5597cab95c
20 changed files with 739 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
57
app/blueprints/api/v1/party_share_blueprint.rb
Normal file
57
app/blueprints/api/v1/party_share_blueprint.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
67
app/controllers/api/v1/party_shares_controller.rb
Normal file
67
app/controllers/api/v1/party_shares_controller.rb
Normal file
|
|
@ -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
|
||||
77
app/errors/party_share_errors.rb
Normal file
77
app/errors/party_share_errors.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
63
app/models/party_share.rb
Normal file
63
app/models/party_share.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -61,16 +61,31 @@ 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?
|
||||
|
||||
# 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.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
DataMigrate::Data.define(version: 20251230000002)
|
||||
DataMigrate::Data.define(version: 20260104000002)
|
||||
|
|
|
|||
21
db/migrate/20260105053753_create_party_shares.rb
Normal file
21
db/migrate/20260105053753_create_party_shares.rb
Normal file
|
|
@ -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
|
||||
19
db/schema.rb
19
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"
|
||||
|
|
|
|||
17
spec/factories/party_shares.rb
Normal file
17
spec/factories/party_shares.rb
Normal file
|
|
@ -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
|
||||
121
spec/models/party_share_spec.rb
Normal file
121
spec/models/party_share_spec.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
139
spec/requests/api/v1/party_shares_spec.rb
Normal file
139
spec/requests/api/v1/party_shares_spec.rb
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue