From 3b9af1b021999fbb7032108e92e23875f237cabc Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 09:25:08 +0100 Subject: [PATCH 01/12] add nominee, requester and status columns to ownership transfers --- ...0506_add_nominee_and_status_to_ownership_transfers.rb | 9 +++++++++ db/schema.rb | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb diff --git a/db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb b/db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb new file mode 100644 index 000000000..e3d1a540b --- /dev/null +++ b/db/migrate/20260915080506_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, :integer, null: false, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index b05ab35a8..3167c57f8 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_080506) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -226,7 +226,10 @@ 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.integer "status", default: 0, null: false t.datetime "updated_at", null: false t.index ["school_id"], name: "index_ownership_transfers_on_school_id" end From 768f1331b012147d0f5834f3efaa71c8bdefaa0c Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 10:37:19 +0100 Subject: [PATCH 02/12] Revert "add nominee, requester and status columns to ownership transfers" This reverts commit 3b9af1b021999fbb7032108e92e23875f237cabc. --- ...0506_add_nominee_and_status_to_ownership_transfers.rb | 9 --------- db/schema.rb | 5 +---- 2 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb diff --git a/db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb b/db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb deleted file mode 100644 index e3d1a540b..000000000 --- a/db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb +++ /dev/null @@ -1,9 +0,0 @@ -# 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, :integer, null: false, default: 0 - end -end diff --git a/db/schema.rb b/db/schema.rb index 3167c57f8..b05ab35a8 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_080506) do +ActiveRecord::Schema[8.1].define(version: 2026_09_11_104254) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -226,10 +226,7 @@ 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.integer "status", default: 0, null: false t.datetime "updated_at", null: false t.index ["school_id"], name: "index_ownership_transfers_on_school_id" end From e8eceae0a02f1541f58597d00654f3503a7fef09 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 11:13:15 +0100 Subject: [PATCH 03/12] add string enum status, nominee and requester to ownership_transfers table --- ...3824_add_nominee_and_status_to_ownership_transfers.rb | 9 +++++++++ db/schema.rb | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb 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..35675e8dc --- /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: 'pre_pending' + end +end diff --git a/db/schema.rb b/db/schema.rb index b05ab35a8..b293d4fba 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_093824) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -226,7 +226,10 @@ 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: "pre_pending", null: false t.datetime "updated_at", null: false t.index ["school_id"], name: "index_ownership_transfers_on_school_id" end From 3aa9ec62345df38a1a928320aa25c867373f1c4d Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 12:44:43 +0100 Subject: [PATCH 04/12] update owner transfer model, factory and test --- app/models/ownership_transfer.rb | 24 ++++ spec/factories/ownership_transfer.rb | 7 +- spec/models/ownership_transfer_spec.rb | 151 ++++++++++++++++++++++--- 3 files changed, 162 insertions(+), 20 deletions(-) diff --git a/app/models/ownership_transfer.rb b/app/models/ownership_transfer.rb index 1b65ca227..427cd6288 100644 --- a/app/models/ownership_transfer.rb +++ b/app/models/ownership_transfer.rb @@ -4,13 +4,37 @@ class OwnershipTransfer < ApplicationRecord delegate :name, to: :school, prefix: true belongs_to :school + + enum :status, { + pre_pending: 'pre_pending', pending: 'pending', + pre_completion: 'pre_completion', completed: 'completed', + pre_rejected: 'pre_rejected', rejected: 'rejected', + pre_cancelled: 'pre_cancelled', cancelled: 'cancelled' + }, default: :pre_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') } + 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 + + nominated_user = User.from_userinfo(ids: [nominated_user_id]).first + + return if nominated_user.school_owner?(school) + return if nominated_user.school_teacher?(school) + + 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/spec/factories/ownership_transfer.rb b/spec/factories/ownership_transfer.rb index 33c89a03a..c5b3f458e 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 { 'pre_pending' } end end diff --git a/spec/models/ownership_transfer_spec.rb b/spec/models/ownership_transfer_spec.rb index 618ef0057..c96685252 100644 --- a/spec/models/ownership_transfer_spec.rb +++ b/spec/models/ownership_transfer_spec.rb @@ -5,37 +5,152 @@ 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:) } + + before do + stub_user_info_api_fetch_by_ids(user_ids: [nominee.id]) end - it 'is invalid with an incorrectly formatted email address' do - ownership_transfer = build(:ownership_transfer, email_address: 'not-an-email-address') + 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 - expect(ownership_transfer).not_to be_valid + 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 'sends an ownership transfer request email after create' do - school = create(:verified_school) + describe 'status' do + it 'defaults to pre_pending on a new record' do + expect(ownership_transfer.status).to eq('pre_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 - ownership_transfer = described_class.create!(email_address: 'new-owner@example.com', school:) + it 'is invalid when set to a status outside the enum' do + ownership_transfer.status = 'made-up-status' - assert_enqueued_email_with SchoolOwnershipMailer, :request_ownership_transfer, params: { ownership_transfer: } + expect(ownership_transfer).not_to be_valid + end + + it 'exposes a predicate for the current status' do + described_class.statuses.each_key do |status| + ownership_transfer.status = status + + 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 'does not run when nominated_user_id is unchanged' do + ownership_transfer.save! + ownership_transfer.update!(status: :pending) + + # only the save! above should have looked up the nominee; the role check + # is skipped on update since nominated_user_id isn't changing + expect(UserInfoApiClient).to have_received(:fetch_by_ids).once + end + + 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:) + stub_user_info_api_fetch_by_ids(user_ids: [owner.id]) + 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:) + stub_user_info_api_fetch_by_ids(user_ids: [student.id]) + 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) + stub_user_info_api_fetch_by_ids(user_ids: [other_teacher.id]) + 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:) + stub_user_info_api_fetch_by_ids(user_ids: [student.id]) + 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 From 08ace9621b352944bc0bb9fc92fea41e810f006a Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 13:01:58 +0100 Subject: [PATCH 05/12] add has many association to school --- app/models/school.rb | 1 + spec/models/school_spec.rb | 11 +++++++++++ 2 files changed, 12 insertions(+) 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/spec/models/school_spec.rb b/spec/models/school_spec.rb index cca6f977c..af610a072 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -41,6 +41,17 @@ 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:) + stub_user_info_api_fetch_by_ids(user_ids: [owner_one.user_id]) + create(:ownership_transfer, school:, requested_by_user_id: owner_one.user_id, nominated_user_id: owner_one.user_id) + stub_user_info_api_fetch_by_ids(user_ids: [owner_two.user_id]) + 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:) } From 521a896c02f626a5802971f066d0bd4c79cb1334 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 15:18:32 +0100 Subject: [PATCH 06/12] redo migration with updated status default --- ...60915093824_add_nominee_and_status_to_ownership_transfers.rb | 2 +- db/schema.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 35675e8dc..6db23a243 100644 --- a/db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb +++ b/db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb @@ -4,6 +4,6 @@ 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: 'pre_pending' + add_column :ownership_transfers, :status, :string, null: false, default: 'pending' end end diff --git a/db/schema.rb b/db/schema.rb index b293d4fba..fa1861ef6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -229,7 +229,7 @@ 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: "pre_pending", 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" end From 9649cac66a2fe51df3a26790d74af5c68135a3f4 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 15:33:46 +0100 Subject: [PATCH 07/12] update models and test with status default pending and simplify nominated_user logic --- app/models/ownership_transfer.rb | 12 +++--------- spec/factories/ownership_transfer.rb | 2 +- spec/models/ownership_transfer_spec.rb | 22 ++-------------------- spec/models/school_spec.rb | 4 +--- 4 files changed, 7 insertions(+), 33 deletions(-) diff --git a/app/models/ownership_transfer.rb b/app/models/ownership_transfer.rb index 427cd6288..d364b8234 100644 --- a/app/models/ownership_transfer.rb +++ b/app/models/ownership_transfer.rb @@ -6,11 +6,8 @@ class OwnershipTransfer < ApplicationRecord belongs_to :school enum :status, { - pre_pending: 'pre_pending', pending: 'pending', - pre_completion: 'pre_completion', completed: 'completed', - pre_rejected: 'pre_rejected', rejected: 'rejected', - pre_cancelled: 'pre_cancelled', cancelled: 'cancelled' - }, default: :pre_pending, validate: true + 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 @@ -26,10 +23,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 - nominated_user = User.from_userinfo(ids: [nominated_user_id]).first - - return if nominated_user.school_owner?(school) - return if nominated_user.school_teacher?(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) diff --git a/spec/factories/ownership_transfer.rb b/spec/factories/ownership_transfer.rb index c5b3f458e..3e630476b 100644 --- a/spec/factories/ownership_transfer.rb +++ b/spec/factories/ownership_transfer.rb @@ -6,6 +6,6 @@ email_address { Faker::Internet.email } nominated_user_id { SecureRandom.uuid } requested_by_user_id { SecureRandom.uuid } - status { 'pre_pending' } + status { 'pending' } end end diff --git a/spec/models/ownership_transfer_spec.rb b/spec/models/ownership_transfer_spec.rb index c96685252..e9dede049 100644 --- a/spec/models/ownership_transfer_spec.rb +++ b/spec/models/ownership_transfer_spec.rb @@ -10,10 +10,6 @@ let(:school) { create(:verified_school) } let(:nominee) { create(:teacher, school:) } - before do - stub_user_info_api_fetch_by_ids(user_ids: [nominee.id]) - end - describe 'validations' do it 'has a valid factory' do expect(ownership_transfer).to be_valid @@ -57,8 +53,8 @@ end describe 'status' do - it 'defaults to pre_pending on a new record' do - expect(ownership_transfer.status).to eq('pre_pending') + 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 @@ -94,22 +90,12 @@ end describe 'nominee role validation' do - it 'does not run when nominated_user_id is unchanged' do - ownership_transfer.save! - ownership_transfer.update!(status: :pending) - - # only the save! above should have looked up the nominee; the role check - # is skipped on update since nominated_user_id isn't changing - expect(UserInfoApiClient).to have_received(:fetch_by_ids).once - end - 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:) - stub_user_info_api_fetch_by_ids(user_ids: [owner.id]) ownership_transfer.nominated_user_id = owner.id expect(ownership_transfer).to be_valid @@ -117,7 +103,6 @@ it 'is invalid when the nominee has only the student role for the school' do student = create(:student, school:) - stub_user_info_api_fetch_by_ids(user_ids: [student.id]) ownership_transfer.nominated_user_id = student.id expect(ownership_transfer).not_to be_valid @@ -126,7 +111,6 @@ 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) - stub_user_info_api_fetch_by_ids(user_ids: [other_teacher.id]) ownership_transfer.nominated_user_id = other_teacher.id expect(ownership_transfer).not_to be_valid @@ -134,9 +118,7 @@ it 'adds an error naming the nominated_user_id and the school id' do student = create(:student, school:) - stub_user_info_api_fetch_by_ids(user_ids: [student.id]) ownership_transfer.nominated_user_id = student.id - ownership_transfer.valid? expect(ownership_transfer.errors[:nominated_user_id].first).to include(student.id) diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index af610a072..50bd17ee3 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -44,11 +44,9 @@ it 'has many ownership transfers' do owner_one = create(:owner_role, school:) owner_two = create(:owner_role, school:) - stub_user_info_api_fetch_by_ids(user_ids: [owner_one.user_id]) create(:ownership_transfer, school:, requested_by_user_id: owner_one.user_id, nominated_user_id: owner_one.user_id) - stub_user_info_api_fetch_by_ids(user_ids: [owner_two.user_id]) 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 From be71d4be2875499faed11d70da37d6d0e83dba6a Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 15 Sep 2026 17:05:19 +0200 Subject: [PATCH 08/12] fix: prevent concurrent pending ownership transfers for a school --- app/models/ownership_transfer.rb | 3 ++ config/locales/en.yml | 2 + ...ue_pending_index_to_ownership_transfers.rb | 13 +++++++ db/schema.rb | 3 +- spec/models/ownership_transfer_spec.rb | 38 +++++++++++++++++++ spec/models/school_spec.rb | 8 +++- 6 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260915100000_add_unique_pending_index_to_ownership_transfers.rb diff --git a/app/models/ownership_transfer.rb b/app/models/ownership_transfer.rb index d364b8234..1c4c6d9fa 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 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/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 fa1861ef6..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_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/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 50bd17ee3..d3fe3fa82 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) From 3d7abd23d29f8872483eb6484af8ab53666fb41f Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 15 Sep 2026 17:06:35 +0200 Subject: [PATCH 09/12] feat: add endpoint to create a school ownership transfer --- .../api/ownership_transfers_controller.rb | 29 ++++ app/models/ability.rb | 1 + config/routes.rb | 1 + lib/concepts/ownership_transfer/create.rb | 37 +++++ .../creating_an_ownership_transfer_spec.rb | 127 ++++++++++++++++++ 5 files changed, 195 insertions(+) create mode 100644 app/controllers/api/ownership_transfers_controller.rb create mode 100644 lib/concepts/ownership_transfer/create.rb create mode 100644 spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb diff --git a/app/controllers/api/ownership_transfers_controller.rb b/app/controllers/api/ownership_transfers_controller.rb new file mode 100644 index 000000000..aa791119a --- /dev/null +++ b/app/controllers/api/ownership_transfers_controller.rb @@ -0,0 +1,29 @@ +# 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 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 + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index c5c096990..d30240713 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -82,6 +82,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(: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]) diff --git a/config/routes.rb b/config/routes.rb index 56c04b11b..753cc24ef 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[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/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/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..d38643601 --- /dev/null +++ b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating an ownership transfer', type: :request do + include ActionMailer::TestHelper + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + let(:nominee) { create(:teacher, school:) } + 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 From dc715085e27a708a529b4116faec413eb5cb47fa Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 15 Sep 2026 17:08:13 +0200 Subject: [PATCH 10/12] feat: add endpoint to view a school's ownership transfer status --- .../api/ownership_transfers_controller.rb | 24 ++++ app/models/ability.rb | 6 +- config/routes.rb | 2 +- .../creating_an_ownership_transfer_spec.rb | 6 +- .../viewing_ownership_transfer_status_spec.rb | 111 ++++++++++++++++++ .../ownership_transfer_context.rb | 8 ++ .../ownership_transfer_examples.rb | 8 ++ 7 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb create mode 100644 spec/support/shared_contexts/ownership_transfer_context.rb create mode 100644 spec/support/shared_examples/ownership_transfer_examples.rb diff --git a/app/controllers/api/ownership_transfers_controller.rb b/app/controllers/api/ownership_transfers_controller.rb index aa791119a..7a95b0adf 100644 --- a/app/controllers/api/ownership_transfers_controller.rb +++ b/app/controllers/api/ownership_transfers_controller.rb @@ -6,6 +6,18 @@ class OwnershipTransfersController < ApiController load_and_authorize_resource :school authorize_resource :ownership_transfer, class: false + def show + @ownership_transfer = pending_ownership_transfer + + if @ownership_transfer.blank? || cannot?(:read, @ownership_transfer) + head :not_found + elsif current_user_is_requester? + render json: { you_are: 'owner', nominee_name: nominee_name }, status: :ok + else + render json: { 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) @@ -25,5 +37,17 @@ def ownership_transfer_params def nominated_user_id ownership_transfer_params[:nominated_user_id] end + + def pending_ownership_transfer + @school.ownership_transfers.pending.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 d30240713..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,7 +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(:create, :ownership_transfer) + 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]) @@ -99,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/config/routes.rb b/config/routes.rb index 753cc24ef..ba8945c18 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -87,7 +87,7 @@ resources :owners, only: %i[index], controller: 'school_owners' resources :teachers, only: %i[index create], controller: 'school_teachers' - resource :ownership_transfer, only: %i[create], controller: 'ownership_transfers' + 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/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb index d38643601..5710043af 100644 --- a/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb +++ b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb @@ -5,10 +5,8 @@ RSpec.describe 'Creating an ownership transfer', type: :request do include ActionMailer::TestHelper - let(:headers) { { Authorization: UserProfileMock::TOKEN } } - let(:school) { create(:school) } - let(:owner) { create(:owner, school:) } - let(:nominee) { create(:teacher, school:) } + include_context 'with a school owner and nominated teacher' + let(:params) { { ownership_transfer: { nominated_user_id: nominee.id } } } before do 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..4a79c5382 --- /dev/null +++ b/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb @@ -0,0 +1,111 @@ +# 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 there is no pending transfer for the school' 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 + 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 + 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 pending transfer is no longer pending' do + before do + ownership_transfer.update!(status: :completed) + authenticated_in_hydra_as(owner) + end + + it 'responds 404 Not Found' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:not_found) + end + end + end +end 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 From b611bf530bc901ec1520f5d3294ac1070bc6f852 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 15 Sep 2026 17:15:22 +0200 Subject: [PATCH 11/12] fix: use a nominee with a real role in the ownership mailer spec --- spec/mailers/school_ownership_mailer_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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') From 69a8eed9aa51c4cccd448cdbeb75000e59347bc0 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 15 Sep 2026 18:35:36 +0200 Subject: [PATCH 12/12] feat: show the transfer's actual status instead of hiding non-pending ones --- .../api/ownership_transfers_controller.rb | 10 +-- .../viewing_ownership_transfer_status_spec.rb | 67 +++++++++++++++++-- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/app/controllers/api/ownership_transfers_controller.rb b/app/controllers/api/ownership_transfers_controller.rb index 7a95b0adf..5c4775585 100644 --- a/app/controllers/api/ownership_transfers_controller.rb +++ b/app/controllers/api/ownership_transfers_controller.rb @@ -7,14 +7,14 @@ class OwnershipTransfersController < ApiController authorize_resource :ownership_transfer, class: false def show - @ownership_transfer = pending_ownership_transfer + @ownership_transfer = most_recent_ownership_transfer if @ownership_transfer.blank? || cannot?(:read, @ownership_transfer) head :not_found elsif current_user_is_requester? - render json: { you_are: 'owner', nominee_name: nominee_name }, status: :ok + render json: { status: @ownership_transfer.status, you_are: 'owner', nominee_name: nominee_name }, status: :ok else - render json: { you_are: 'nominee' }, status: :ok + render json: { status: @ownership_transfer.status, you_are: 'nominee' }, status: :ok end end @@ -38,8 +38,8 @@ def nominated_user_id ownership_transfer_params[:nominated_user_id] end - def pending_ownership_transfer - @school.ownership_transfers.pending.order(created_at: :desc).first + def most_recent_ownership_transfer + @school.ownership_transfers.order(created_at: :desc).first end def current_user_is_requester? diff --git a/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb b/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb index 4a79c5382..31d1bce02 100644 --- a/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb +++ b/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb @@ -18,7 +18,7 @@ expect(response).to have_http_status(:forbidden) end - context 'when there is no pending transfer for the school' do + context 'when the school has never had an ownership transfer' do before { authenticated_in_hydra_as(owner) } it 'responds 404 Not Found' do @@ -62,6 +62,13 @@ 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 @@ -78,6 +85,13 @@ 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 @@ -96,16 +110,61 @@ it_behaves_like 'a hidden ownership transfer' end - context 'when the pending transfer is no longer pending' do + 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 404 Not Found' do + it 'responds 200 OK, still visible to the requester' do get("/api/schools/#{school.id}/ownership_transfer", headers:) - expect(response).to have_http_status(:not_found) + + 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