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:
Justin Edmund 2026-01-05 02:39:32 -08:00 committed by GitHub
parent c3d9efa349
commit 5597cab95c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 739 additions and 9 deletions

View file

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

View 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

View file

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

View file

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

View file

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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20251230000002)
DataMigrate::Data.define(version: 20260104000002)

View 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

View file

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

View 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

View 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

View file

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

View 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