diff --git a/app/controllers/api/ownership_transfers_controller.rb b/app/controllers/api/ownership_transfers_controller.rb new file mode 100644 index 000000000..5c4775585 --- /dev/null +++ b/app/controllers/api/ownership_transfers_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Api + class OwnershipTransfersController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + authorize_resource :ownership_transfer, class: false + + def show + @ownership_transfer = most_recent_ownership_transfer + + if @ownership_transfer.blank? || cannot?(:read, @ownership_transfer) + head :not_found + elsif current_user_is_requester? + render json: { status: @ownership_transfer.status, you_are: 'owner', nominee_name: nominee_name }, status: :ok + else + render json: { status: @ownership_transfer.status, you_are: 'nominee' }, status: :ok + end + end + + def create + result = OwnershipTransfer::Create.call(school: @school, nominated_user_id:, requested_by_user_id: current_user.id) + + if result.success? + head :created + else + render json: { error: result[:error] }, status: :unprocessable_content + end + end + + private + + def ownership_transfer_params + params.expect(ownership_transfer: [:nominated_user_id]) + end + + def nominated_user_id + ownership_transfer_params[:nominated_user_id] + end + + def most_recent_ownership_transfer + @school.ownership_transfers.order(created_at: :desc).first + end + + def current_user_is_requester? + @ownership_transfer.requested_by_user_id == current_user.id + end + + def nominee_name + User.from_userinfo(ids: @ownership_transfer.nominated_user_id).first&.name + end + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index c5c096990..ac365cee3 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -39,6 +39,9 @@ def define_authenticated_abilities(user) invitation.email_address.present? && invitation.email_address.casecmp?(user.email) end + can :read, OwnershipTransfer do |transfer| + user.id == transfer.requested_by_user_id || user.id == transfer.nominated_user_id + end end def define_authenticated_non_student_abilities(user) @@ -82,6 +85,7 @@ def define_school_owner_abilities(school:) can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id } }) can(%i[read create destroy], :school_owner) can(%i[read create destroy], :school_teacher) + can(%i[read create], :ownership_transfer) can(%i[read create create_batch update destroy destroy_batch], :school_student) can(%i[create create_copy], Lesson, school_id: school.id) can(%i[read update destroy], Lesson, school_id: school.id, visibility: %w[teachers students public]) @@ -98,6 +102,7 @@ def define_school_teacher_abilities(user:, school:) can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id }, teachers: { teacher_id: user.id } }) can(%i[read], :school_owner) can(%i[read], :school_teacher) + can(:read, :ownership_transfer) can(%i[read create create_batch update], :school_student) can(%i[create update destroy], Lesson) do |lesson| school_teacher_can_manage_lesson?(user:, school:, lesson:) diff --git a/app/models/ownership_transfer.rb b/app/models/ownership_transfer.rb index d364b8234..b74102682 100644 --- a/app/models/ownership_transfer.rb +++ b/app/models/ownership_transfer.rb @@ -13,6 +13,9 @@ class OwnershipTransfer < ApplicationRecord validates :requested_by_user_id, presence: true validates :email_address, format: { with: EmailValidator.regexp, message: I18n.t('validations.invitation.email_address') } + validates :school_id, + uniqueness: { conditions: -> { where(status: :pending) }, message: I18n.t('validations.ownership_transfer.school_pending') }, + on: :create validate :nominee_has_the_school_owner_or_school_teacher_role_for_the_school after_create_commit :send_ownership_transfer_request_email @@ -23,7 +26,7 @@ class OwnershipTransfer < ApplicationRecord def nominee_has_the_school_owner_or_school_teacher_role_for_the_school return unless nominated_user_id_changed? && errors.blank? && school - return if school.roles.exists?(user_id: nominated_user_id, role: %i[owner teacher]) + return if school.owner_or_teacher?(nominated_user_id) msg = "'#{nominated_user_id}' does not have the 'owner' or 'teacher' role for school '#{school.id}'" errors.add(:nominated_user_id, msg) diff --git a/app/models/school.rb b/app/models/school.rb index 479d9bd96..abf010d36 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -133,6 +133,10 @@ def student_count roles.student.count end + def owner_or_teacher?(user_id) + roles.exists?(user_id:, role: %i[owner teacher]) + end + def postal_code=(str) super(str.to_s.upcase) end diff --git a/config/locales/en.yml b/config/locales/en.yml index 7c9c88e49..efd4f0193 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -23,6 +23,8 @@ en: school_roll_number_exists: "School roll number already exists" invitation: email_address: "'%s' is invalid" + ownership_transfer: + school_pending: "already has a pending ownership transfer" activerecord: attributes: school_class: diff --git a/config/routes.rb b/config/routes.rb index 56c04b11b..ba8945c18 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -87,6 +87,7 @@ resources :owners, only: %i[index], controller: 'school_owners' resources :teachers, only: %i[index create], controller: 'school_teachers' + resource :ownership_transfer, only: %i[show create], controller: 'ownership_transfers' resources :students, only: %i[index create update destroy], controller: 'school_students' do post :batch, on: :collection, to: 'school_students#create_batch' delete :batch, on: :collection, to: 'school_students#destroy_batch' diff --git a/db/migrate/20260915100000_add_unique_pending_index_to_ownership_transfers.rb b/db/migrate/20260915100000_add_unique_pending_index_to_ownership_transfers.rb new file mode 100644 index 000000000..10360484b --- /dev/null +++ b/db/migrate/20260915100000_add_unique_pending_index_to_ownership_transfers.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddUniquePendingIndexToOwnershipTransfers < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + def change + add_index :ownership_transfers, :school_id, + unique: true, + where: "status = 'pending'", + name: 'index_ownership_transfers_on_school_id_when_pending', + algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 8c7c8e2df..10401ac92 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.1].define(version: 2026_09_15_093824) do +ActiveRecord::Schema[8.1].define(version: 2026_09_15_100000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -232,6 +232,7 @@ t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["school_id"], name: "index_ownership_transfers_on_school_id" + t.index ["school_id"], name: "index_ownership_transfers_on_school_id_when_pending", unique: true, where: "((status)::text = 'pending'::text)" end create_table "project_errors", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/lib/concepts/ownership_transfer/create.rb b/lib/concepts/ownership_transfer/create.rb new file mode 100644 index 000000000..c8d323819 --- /dev/null +++ b/lib/concepts/ownership_transfer/create.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class OwnershipTransfer + class Create + class << self + def call(school:, nominated_user_id:, requested_by_user_id:) + response = OperationResponse.new + ownership_transfer = build_ownership_transfer(school:, nominated_user_id:, requested_by_user_id:) + + if ownership_transfer.save + response[:ownership_transfer] = ownership_transfer + else + response[:error] = ownership_transfer.errors + end + + response + rescue ActiveRecord::RecordNotUnique + response ||= OperationResponse.new + ownership_transfer.errors.add(:school_id, I18n.t('validations.ownership_transfer.school_pending')) + response[:error] = ownership_transfer.errors + response + rescue StandardError => e + response ||= OperationResponse.new + Sentry.capture_exception(e) + response[:error] = "Error creating ownership transfer: #{e}" + response + end + + private + + def build_ownership_transfer(school:, nominated_user_id:, requested_by_user_id:) + email_address = nominee_email(school:, nominated_user_id:) + OwnershipTransfer.new(school:, nominated_user_id:, requested_by_user_id:, email_address:) + end + + def nominee_email(school:, nominated_user_id:) + return unless school.owner_or_teacher?(nominated_user_id) + + User.from_userinfo(ids: nominated_user_id).first&.email + end + end + end +end diff --git a/spec/concepts/ownership_transfer/create_spec.rb b/spec/concepts/ownership_transfer/create_spec.rb new file mode 100644 index 000000000..d4d6677e1 --- /dev/null +++ b/spec/concepts/ownership_transfer/create_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe OwnershipTransfer::Create, type: :unit do + let(:school) { create(:verified_school) } + let(:owner) { create(:owner, school:) } + let(:nominee) { create(:teacher, school:) } + + before { stub_user_info_api_for(nominee) } + + it 'returns a successful response and creates the transfer' do + response = described_class.call(school:, nominated_user_id: nominee.id, requested_by_user_id: owner.id) + + expect(response.success?).to be(true) + expect(response[:ownership_transfer]).to have_attributes( + school:, + nominated_user_id: nominee.id, + requested_by_user_id: owner.id, + email_address: nominee.email + ) + end + + it 'returns a failure response when the nominee has no role at the school' do + response = described_class.call(school:, nominated_user_id: SecureRandom.uuid, requested_by_user_id: owner.id) + + expect(response.failure?).to be(true) + expect(response[:error]).to be_present + end + + context 'when a duplicate pending transfer is created concurrently' do + before do + allow(OwnershipTransfer).to receive(:new).and_wrap_original do |method, *args| + method.call(*args).tap do |ownership_transfer| + allow(ownership_transfer).to receive(:save).and_raise(ActiveRecord::RecordNotUnique) + end + end + end + + it 'returns the same friendly error a non-concurrent duplicate would get, not the raw exception' do + response = described_class.call(school:, nominated_user_id: nominee.id, requested_by_user_id: owner.id) + + expect(response.failure?).to be(true) + expect(response[:error][:school_id]).to include('already has a pending ownership transfer') + end + + it 'does not report the race to Sentry, since it is an expected, handled outcome' do + allow(Sentry).to receive(:capture_exception) + + described_class.call(school:, nominated_user_id: nominee.id, requested_by_user_id: owner.id) + + expect(Sentry).not_to have_received(:capture_exception) + end + end + + context 'when an unexpected error occurs' do + before do + allow(OwnershipTransfer).to receive(:new).and_raise(StandardError, 'boom') + allow(Sentry).to receive(:capture_exception) + end + + it 'reports it to Sentry and returns a generic error' do + response = described_class.call(school:, nominated_user_id: nominee.id, requested_by_user_id: owner.id) + + expect(Sentry).to have_received(:capture_exception) + expect(response.failure?).to be(true) + expect(response[:error]).to include('Error creating ownership transfer') + end + end +end diff --git a/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb new file mode 100644 index 000000000..646f16713 --- /dev/null +++ b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating an ownership transfer', type: :request do + include ActionMailer::TestHelper + + include_context 'with a school owner and nominated teacher' + + let(:params) { { ownership_transfer: { nominated_user_id: nominee.id } } } + + before do + stub_user_info_api_for(nominee) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/ownership_transfer", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + authenticated_in_hydra_as(nominee) + + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is the owner of a different school' do + authenticated_in_hydra_as(owner) + Role.owner.find_by(user_id: owner.id, school:).delete + Role.teacher.find_by(user_id: nominee.id, school:).delete + school.update!(id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:forbidden) + end + + context 'when the current user is the school owner' do + before { authenticated_in_hydra_as(owner) } + + context 'when the nominee has the teacher role at the school' do + it 'responds 201 Created' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:created) + end + + it 'creates an ownership transfer for the nominee' do + expect do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + end.to change(OwnershipTransfer, :count).by(1) + + expect(OwnershipTransfer.last).to have_attributes( + school:, + nominated_user_id: nominee.id, + requested_by_user_id: owner.id, + email_address: nominee.email, + status: 'pending' + ) + end + + it 'sends the ownership transfer request email' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + + assert_enqueued_email_with( + SchoolOwnershipMailer, + :request_ownership_transfer, + params: { ownership_transfer: OwnershipTransfer.last } + ) + end + end + + context 'when the nominee does not have the owner or teacher role at the school' do + let(:params) { { ownership_transfer: { nominated_user_id: SecureRandom.uuid } } } + + it 'responds 422 Unprocessable entity' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:unprocessable_content) + end + + it 'includes a validation error in the response body' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + + json = JSON.parse(response.body) + expect(json['error']).to be_present + end + + it 'does not create an ownership transfer' do + expect do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + end.not_to change(OwnershipTransfer, :count) + end + end + + context 'when a transfer is already pending for the school' do + before { create(:ownership_transfer, school:, nominated_user_id: nominee.id) } + + it 'responds 422 Unprocessable entity' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:unprocessable_content) + end + + it 'includes the pending-transfer error in the response body' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + + json = JSON.parse(response.body) + expect(json['error']['school_id']).to include('already has a pending ownership transfer') + end + + it 'does not create a second ownership transfer' do + expect do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + end.not_to change(OwnershipTransfer, :count) + end + end + + context 'when a transfer for the school is no longer pending' do + before { create(:ownership_transfer, school:, nominated_user_id: nominee.id, status: :completed) } + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:created) + end + + it 'creates a new ownership transfer' do + expect do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + end.to change(OwnershipTransfer, :count).by(1) + end + end + end +end diff --git a/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb b/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb new file mode 100644 index 000000000..31d1bce02 --- /dev/null +++ b/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Viewing ownership transfer status', type: :request do + include_context 'with a school owner and nominated teacher' + + it 'responds 401 Unauthorized when no token is given' do + get("/api/schools/#{school.id}/ownership_transfer") + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:forbidden) + end + + context 'when the school has never had an ownership transfer' do + before { authenticated_in_hydra_as(owner) } + + it 'responds 404 Not Found' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:not_found) + end + end + + context 'when there is a pending transfer for the school' do + let!(:ownership_transfer) do + create( + :ownership_transfer, + school:, + nominated_user_id: nominee.id, + requested_by_user_id: owner.id, + email_address: nominee.email + ) + end + + context 'when the current user is the school owner' do + before do + stub_user_info_api_for(nominee) + authenticated_in_hydra_as(owner) + end + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:ok) + end + + it 'identifies the current user as the owner' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + json = JSON.parse(response.body) + expect(json['you_are']).to eq('owner') + end + + it 'includes the nominated teacher\'s name' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + json = JSON.parse(response.body) + expect(json['nominee_name']).to eq(nominee.name) + end + + it 'includes the transfer status' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + json = JSON.parse(response.body) + expect(json['status']).to eq('pending') + end + end + + context 'when the current user is the nominee' do + before { authenticated_in_hydra_as(nominee) } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:ok) + end + + it 'identifies the current user as the nominee' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + json = JSON.parse(response.body) + expect(json['you_are']).to eq('nominee') + end + + it 'includes the transfer status' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + json = JSON.parse(response.body) + expect(json['status']).to eq('pending') + end + end + + context 'when the current user is a different teacher at the school' do + let(:other_teacher) { create(:teacher, school:) } + + before { authenticated_in_hydra_as(other_teacher) } + + it_behaves_like 'a hidden ownership transfer' + end + + context 'when the current user is a different owner of the school who did not request the transfer' do + let(:other_owner) { create(:owner, school:) } + + before { authenticated_in_hydra_as(other_owner) } + + it_behaves_like 'a hidden ownership transfer' + end + + context 'when the transfer has completed' do + before do + ownership_transfer.update!(status: :completed) + stub_user_info_api_for(nominee) + authenticated_in_hydra_as(owner) + end + + it 'responds 200 OK, still visible to the requester' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json).to include('status' => 'completed', 'you_are' => 'owner', 'nominee_name' => nominee.name) + end + end + + context 'when the transfer was rejected' do + before do + ownership_transfer.update!(status: :rejected) + authenticated_in_hydra_as(nominee) + end + + it 'responds 200 OK, still visible to the nominee who rejected it' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json).to include('status' => 'rejected', 'you_are' => 'nominee') + end + end + + context 'when the transfer was cancelled' do + before do + ownership_transfer.update!(status: :cancelled) + authenticated_in_hydra_as(nominee) + end + + it 'responds 200 OK, still visible to the nominee' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json).to include('status' => 'cancelled', 'you_are' => 'nominee') + end + end + + context 'when a resolved transfer is viewed by someone who was never involved' do + let(:other_teacher) { create(:teacher, school:) } + + before do + ownership_transfer.update!(status: :completed) + authenticated_in_hydra_as(other_teacher) + end + + it_behaves_like 'a hidden ownership transfer' + end + end +end diff --git a/spec/models/ownership_transfer_spec.rb b/spec/models/ownership_transfer_spec.rb index e9dede049..814496ac2 100644 --- a/spec/models/ownership_transfer_spec.rb +++ b/spec/models/ownership_transfer_spec.rb @@ -52,6 +52,44 @@ end end + describe 'pending transfer uniqueness' do + it 'is invalid when the school already has a pending transfer' do + create(:ownership_transfer, school:, nominated_user_id: nominee.id) + + second_transfer = build(:ownership_transfer, school:, nominated_user_id: nominee.id) + + expect(second_transfer).not_to be_valid + expect(second_transfer.errors[:school_id]).to include('already has a pending ownership transfer') + end + + it 'is valid for a second school even when another school has a pending transfer' do + create(:ownership_transfer, school:, nominated_user_id: nominee.id) + + other_school = create(:verified_school) + other_nominee = create(:teacher, school: other_school) + second_transfer = build(:ownership_transfer, school: other_school, nominated_user_id: other_nominee.id) + + expect(second_transfer).to be_valid + end + + it "is valid when the school's only existing transfer is no longer pending" do + create(:ownership_transfer, school:, nominated_user_id: nominee.id, status: :completed) + + second_transfer = build(:ownership_transfer, school:, nominated_user_id: nominee.id) + + expect(second_transfer).to be_valid + end + + it 'rejects a duplicate pending transfer created concurrently, bypassing application-level validation' do + first_transfer = build(:ownership_transfer, school:, nominated_user_id: nominee.id) + second_transfer = build(:ownership_transfer, school:, nominated_user_id: nominee.id) + + first_transfer.save!(validate: false) + + expect { second_transfer.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique) + end + end + describe 'status' do it 'defaults to pending on a new record' do expect(ownership_transfer.status).to eq('pending') diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 0d1382d33..3fcb0b829 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -44,7 +44,13 @@ it 'has many ownership transfers' do owner_one = create(:owner_role, school:) owner_two = create(:owner_role, school:) - create(:ownership_transfer, school:, requested_by_user_id: owner_one.user_id, nominated_user_id: owner_one.user_id) + create( + :ownership_transfer, + school:, + requested_by_user_id: owner_one.user_id, + nominated_user_id: owner_one.user_id, + status: :completed + ) create(:ownership_transfer, school:, requested_by_user_id: owner_two.user_id, nominated_user_id: owner_two.user_id) expect(school.ownership_transfers.size).to eq(2) @@ -111,6 +117,25 @@ end end + describe '#owner_or_teacher?' do + it 'is true for a user with the owner role at the school' do + owner = create(:owner, school:) + expect(school.owner_or_teacher?(owner.id)).to be(true) + end + + it 'is true for a user with the teacher role at the school' do + expect(school.owner_or_teacher?(teacher.id)).to be(true) + end + + it 'is false for a user with only the student role at the school' do + expect(school.owner_or_teacher?(student.id)).to be(false) + end + + it 'is false for a user with no role at the school' do + expect(school.owner_or_teacher?(SecureRandom.uuid)).to be(false) + end + end + describe 'validations' do subject(:school) { create(:school) } diff --git a/spec/support/shared_contexts/ownership_transfer_context.rb b/spec/support/shared_contexts/ownership_transfer_context.rb new file mode 100644 index 000000000..6f5d8fb20 --- /dev/null +++ b/spec/support/shared_contexts/ownership_transfer_context.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +RSpec.shared_context 'with a school owner and nominated teacher' do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + let(:nominee) { create(:teacher, school:) } +end diff --git a/spec/support/shared_examples/ownership_transfer_examples.rb b/spec/support/shared_examples/ownership_transfer_examples.rb new file mode 100644 index 000000000..c1ad78d32 --- /dev/null +++ b/spec/support/shared_examples/ownership_transfer_examples.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a hidden ownership transfer' do + it 'responds 404 Not Found, without revealing that a transfer exists' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:not_found) + end +end