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 1b65ca227..1c4c6d9fa 100644 --- a/app/models/ownership_transfer.rb +++ b/app/models/ownership_transfer.rb @@ -4,13 +4,34 @@ class OwnershipTransfer < ApplicationRecord delegate :name, to: :school, prefix: true belongs_to :school + + enum :status, { + pending: 'pending', completed: 'completed', rejected: 'rejected', cancelled: 'cancelled' + }, default: :pending, validate: true + + validates :nominated_user_id, presence: true + 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 encrypts :email_address private + 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]) + + msg = "'#{nominated_user_id}' does not have the 'owner' or 'teacher' role for school '#{school.id}'" + errors.add(:nominated_user_id, msg) + end + def send_ownership_transfer_request_email SchoolOwnershipMailer.with(ownership_transfer: self).request_ownership_transfer.deliver_later end diff --git a/app/models/school.rb b/app/models/school.rb index 3ddb8c92d..db4d790c1 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -7,6 +7,7 @@ class School < ApplicationRecord has_many :roles, dependent: :nullify has_many :school_projects, dependent: :nullify has_many :school_email_domains, dependent: :destroy + has_many :ownership_transfers, dependent: :destroy VALID_URL_REGEX = %r{\A(?:https?://)?(?:www.)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,63}(\.[a-z]{2,63})*(/.*)?\z}ix 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/20260915093824_add_nominee_and_status_to_ownership_transfers.rb b/db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb new file mode 100644 index 000000000..6db23a243 --- /dev/null +++ b/db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddNomineeAndStatusToOwnershipTransfers < ActiveRecord::Migration[8.1] + def change + add_column :ownership_transfers, :nominated_user_id, :uuid, null: false + add_column :ownership_transfers, :requested_by_user_id, :uuid, null: false + add_column :ownership_transfers, :status, :string, null: false, default: 'pending' + end +end 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 b05ab35a8..aabf04a02 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_11_104254) 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" @@ -226,9 +226,13 @@ t.datetime "accepted_at" t.datetime "created_at", null: false t.string "email_address" + t.uuid "nominated_user_id", null: false + t.uuid "requested_by_user_id", null: false t.uuid "school_id", null: false + 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..2b77210eb --- /dev/null +++ b/lib/concepts/ownership_transfer/create.rb @@ -0,0 +1,37 @@ +# 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 StandardError => e + 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.roles.exists?(user_id: nominated_user_id, role: %i[owner teacher]) + + User.from_userinfo(ids: nominated_user_id).first&.email + end + end + end +end diff --git a/spec/factories/ownership_transfer.rb b/spec/factories/ownership_transfer.rb index 33c89a03a..3e630476b 100644 --- a/spec/factories/ownership_transfer.rb +++ b/spec/factories/ownership_transfer.rb @@ -2,7 +2,10 @@ FactoryBot.define do factory :ownership_transfer do - email_address { 'new-owner@example.com' } - school factory: :verified_school + school + email_address { Faker::Internet.email } + nominated_user_id { SecureRandom.uuid } + requested_by_user_id { SecureRandom.uuid } + status { 'pending' } 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..5710043af --- /dev/null +++ b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb @@ -0,0 +1,125 @@ +# 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 '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 '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/mailers/school_ownership_mailer_spec.rb b/spec/mailers/school_ownership_mailer_spec.rb index e910221be..e15f1b790 100644 --- a/spec/mailers/school_ownership_mailer_spec.rb +++ b/spec/mailers/school_ownership_mailer_spec.rb @@ -6,7 +6,9 @@ describe 'request_ownership_transfer' do subject(:email) { described_class.with(ownership_transfer:).request_ownership_transfer } - let(:ownership_transfer) { create(:ownership_transfer) } + let(:school) { create(:school) } + let(:nominee) { create(:teacher, school:) } + let(:ownership_transfer) { create(:ownership_transfer, school:, nominated_user_id: nominee.id) } before do allow(ENV).to receive(:fetch).with('EDITOR_PUBLIC_URL').and_return('http://example.com') diff --git a/spec/models/ownership_transfer_spec.rb b/spec/models/ownership_transfer_spec.rb index 618ef0057..814496ac2 100644 --- a/spec/models/ownership_transfer_spec.rb +++ b/spec/models/ownership_transfer_spec.rb @@ -5,37 +5,172 @@ RSpec.describe OwnershipTransfer do include ActionMailer::TestHelper - it 'has a valid factory' do - ownership_transfer = build(:ownership_transfer) + subject(:ownership_transfer) { build(:ownership_transfer, school:, nominated_user_id: nominee.id) } - expect(ownership_transfer).to be_valid + let(:school) { create(:verified_school) } + let(:nominee) { create(:teacher, school:) } + + describe 'validations' do + it 'has a valid factory' do + expect(ownership_transfer).to be_valid + end + + it 'requires a school' do + ownership_transfer.school = nil + + expect(ownership_transfer).not_to be_valid + end + + it 'requires a nominated_user_id' do + ownership_transfer.nominated_user_id = nil + + expect(ownership_transfer).not_to be_valid + end + + it 'requires a requested_by_user_id' do + ownership_transfer.requested_by_user_id = nil + + expect(ownership_transfer).not_to be_valid + end + + it 'requires an email_address' do + ownership_transfer.email_address = nil + + expect(ownership_transfer).not_to be_valid + end + + it 'is invalid with an incorrectly formatted email address' do + ownership_transfer.email_address = 'not-an-email-address' + + expect(ownership_transfer).not_to be_valid + end + + it 'non-deterministically encrypts the email_address' do + ownership_transfer.save! + + expect(described_class.find_by(email_address: ownership_transfer.email_address)).to be_nil + end end - it 'is invalid with an incorrectly formatted email address' do - ownership_transfer = build(:ownership_transfer, email_address: 'not-an-email-address') + 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 - expect(ownership_transfer).not_to be_valid + 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 - it 'sends an ownership transfer request email after create' do - school = create(:verified_school) + describe 'status' do + it 'defaults to pending on a new record' do + expect(ownership_transfer.status).to eq('pending') + end + + it 'is valid for every declared status' do + described_class.statuses.each_key do |status| + ownership_transfer.status = status + + expect(ownership_transfer).to be_valid + end + end + + it 'is invalid when set to a status outside the enum' do + ownership_transfer.status = 'made-up-status' + + expect(ownership_transfer).not_to be_valid + end - ownership_transfer = described_class.create!(email_address: 'new-owner@example.com', school:) + it 'exposes a predicate for the current status' do + described_class.statuses.each_key do |status| + ownership_transfer.status = status - assert_enqueued_email_with SchoolOwnershipMailer, :request_ownership_transfer, params: { ownership_transfer: } + expect(ownership_transfer.public_send("#{status}?")).to be true + end + end + + it 'exposes a scope per status' do + described_class.statuses.each_key do |status| + ownership_transfer.status = status + ownership_transfer.save! + + expect(described_class.public_send(status)).to include(ownership_transfer) + end + end end - it 'delegates #school_name to School#name' do - school = build(:school, name: 'school-name') - ownership_transfer = build(:ownership_transfer, school:) + describe 'nominee role validation' do + it 'is valid when the nominee has the teacher role for the school' do + expect(ownership_transfer).to be_valid + end + + it 'is valid when the nominee has the owner role for the school' do + owner = create(:owner, school:) + ownership_transfer.nominated_user_id = owner.id + + expect(ownership_transfer).to be_valid + end + + it 'is invalid when the nominee has only the student role for the school' do + student = create(:student, school:) + ownership_transfer.nominated_user_id = student.id + + expect(ownership_transfer).not_to be_valid + end + + it 'is invalid when the nominee has a teacher role for a different school' do + other_school = create(:verified_school) + other_teacher = create(:teacher, school: other_school) + ownership_transfer.nominated_user_id = other_teacher.id + + expect(ownership_transfer).not_to be_valid + end + + it 'adds an error naming the nominated_user_id and the school id' do + student = create(:student, school:) + ownership_transfer.nominated_user_id = student.id + ownership_transfer.valid? - expect(ownership_transfer.school_name).to eq('school-name') + expect(ownership_transfer.errors[:nominated_user_id].first).to include(student.id) + expect(ownership_transfer.errors[:nominated_user_id].first).to include(school.id) + end end - it 'non-deterministically encrypts the email_address' do - school = create(:verified_school) - described_class.create!(email_address: 'new-owner@example.com', school:) + describe 'the request email' do + it 'is enqueued with the transfer as the mailer param' do + ownership_transfer.save! - expect(described_class.find_by(email_address: 'new-owner@example.com')).to be_nil + assert_enqueued_email_with( + SchoolOwnershipMailer, :request_ownership_transfer, params: { ownership_transfer: } + ) + end end end diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index cca6f977c..d3fe3fa82 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -41,6 +41,21 @@ expect(school.school_email_domains.size).to eq(2) end + 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, + 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) + end + context 'when a school is destroyed' do let!(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } let!(:lesson_1) { create(:lesson, user_id: teacher.id, school_class:) } 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