diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 666d3d0f..0c67410b 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -7,8 +7,8 @@ def search return if @query.blank? - @users = User.accessible_by(current_ability).where( - 'firstname ILIKE :search OR lastname ILIKE :search OR email ILIKE :search OR room ILIKE :search', + @users = User.accessible_by(current_ability).left_joins(:room).where( + 'firstname ILIKE :search OR lastname ILIKE :search OR email ILIKE :search OR rooms.number ILIKE :search', search: "%#{User.sanitize_sql_like @query}%" ) @machines = Machine.accessible_by(current_ability).where( diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index d182869c..7d0eaa80 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -16,7 +16,6 @@ def create_developer u.firstname = auth_hash[:first_name] u.lastname = auth_hash[:last_name] u.username = auth_hash[:username] - u.room = auth_hash[:room] end user.groups = auth_hash[:groups].split(',') log_in user diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 3bc0d4cf..0aa0d4ca 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -63,6 +63,6 @@ def destroy private def user_params - params.require(:user).permit(:firstname, :lastname, :email, :room, :username) + params.require(:user).permit(:firstname, :lastname, :email, :username, :room_number) end end diff --git a/app/jobs/sync_room_to_sso_job.rb b/app/jobs/sync_room_to_sso_job.rb new file mode 100644 index 00000000..3d504a95 --- /dev/null +++ b/app/jobs/sync_room_to_sso_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class SyncRoomToSsoJob < ApplicationJob + queue_as :default + + retry_on Net::OpenTimeout, Net::ReadTimeout, wait: 30.seconds, attempts: 5 + + def perform(user_id) + user = User.find_by(id: user_id) + return if user.nil? + + SsoMetadataService.new.sync_room(user) + end +end diff --git a/app/models/room.rb b/app/models/room.rb new file mode 100644 index 00000000..a2ec374d --- /dev/null +++ b/app/models/room.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Room < ApplicationRecord + belongs_to :user, optional: true, inverse_of: :room + + validates :number, presence: true, uniqueness: true, length: { maximum: 6 }, + format: { with: /\A[A-Z0-9]+\z/, message: 'must be uppercase alphanumeric' } + # A room group represents the natural grouping of rooms. It can be the room number itself or a shared identifier + validates :group, presence: true, length: { maximum: 6 }, + format: { with: /\A[A-Z0-9]+\z/, message: 'must be uppercase alphanumeric' } + validates :building, presence: true, inclusion: { in: ('A'..'F').to_a } + validates :floor, presence: true, inclusion: { in: 0..3 } + validates :user_id, uniqueness: true, allow_nil: true + + after_commit :enqueue_room_sync_to_sso, on: [:create, :update], if: :saved_change_to_user_id? + + # Returns rooms available for assignment: unoccupied rooms + the room already assigned to the given user + scope :available_for, ->(user) { where(user_id: [nil, user.id]) } + + private + + def enqueue_room_sync_to_sso + SyncRoomToSsoJob.perform_later(user_id) if user_id.present? + SyncRoomToSsoJob.perform_later(user_id_before_last_save) if user_id_before_last_save.present? + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 483b3205..cd240a16 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class User < ApplicationRecord + has_one :room, dependent: :nullify, inverse_of: :user has_many :machines, dependent: :destroy has_many :free_accesses, dependent: :destroy has_many :free_accesses_by_date, lambda { @@ -15,7 +16,6 @@ class User < ApplicationRecord }, through: :sales_as_client, dependent: :destroy, class_name: 'Subscription', source: :subscription normalizes :email, with: ->(email) { email.strip.downcase } - normalizes :room, with: ->(room) { room&.downcase&.upcase_first } # Since the Radius MD4 hash is broken anyway (see: https://kanidm.github.io/kanidm/master/integrations/radius.html#cleartext-credential-storage) # we choose to store the wifi_password encrypted using Rails built-in encryption. @@ -27,14 +27,14 @@ class User < ApplicationRecord validates :lastname, presence: true, allow_blank: false VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/ validates :email, presence: true, format: { with: VALID_EMAIL_REGEX }, uniqueness: true - # TODO: Make room regex case-sensitive once we fix support for 'DF1' with uppercase - VALID_ROOM_REGEX = /\A([A-F][0-3][0-9]{2}[a-b]?|DF[1-4])\z/i - validates :room, format: { with: VALID_ROOM_REGEX }, uniqueness: true, allow_nil: true validates :wifi_password, presence: true, allow_blank: false validates :username, presence: true, uniqueness: true, allow_blank: false + validate :room_number_must_exist before_validation :ensure_has_wifi_password + before_save :assign_room_from_number + # @return [Array] attr_accessor :groups @@ -48,7 +48,7 @@ def display_name end def display_address - address = room.present? ? "Appartement #{room}\n" : '' + address = room.present? ? "Appartement #{room.number}\n" : '' "#{address}Résidence Léonard de Vinci\nAvenue Paul Langevin\n59650 Villeneuve-d'Ascq" end @@ -97,6 +97,14 @@ def update_from_sso(firstname:, lastname:, email:, username:) update(firstname: firstname, lastname: lastname, email: email, username: username) end + def room_number + @room_number || room&.number + end + + def room_number=(value) + @room_number = value&.upcase&.presence + end + def admin? return false if groups.nil? @@ -105,6 +113,17 @@ def admin? private + def room_number_must_exist + return if @room_number.blank? + return if Room.exists?(number: @room_number) + + errors.add(:room_number, 'does not exist') + end + + def assign_room_from_number + self.room = @room_number.blank? ? nil : Room.find_by(number: @room_number) + end + def subscription_expired? subscription_expiration.nil? || (subscription_expiration < Time.current) end diff --git a/app/services/sso_metadata_service.rb b/app/services/sso_metadata_service.rb new file mode 100644 index 00000000..1d5bad53 --- /dev/null +++ b/app/services/sso_metadata_service.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +class SsoMetadataService + SSO_BASE_URL = 'https://sso.rezoleo.fr' + HTTP_OPEN_TIMEOUT_SECONDS = 5 + HTTP_READ_TIMEOUT_SECONDS = 10 + + # @param user [User] + def sync_room(user) + return if user.oidc_id.blank? + + room_number = user.room&.number + + unless production? + Rails.logger.info("[SSO] Dry-run: would sync room '#{room_number}' for user #{user.oidc_id}") + return + end + + push_room_metadata(user, room_number) + end + + private + + def production? + Rails.env.production? + end + + def push_room_metadata(user, room_number) + if room_number.present? + post_room_metadata(user, room_number) + else + delete_room_metadata(user) + end + end + + # Metadata values in Zitadel must be base64-encoded. + # See https://zitadel.com/docs/reference/api/user/zitadel.user.v2.UserService.SetUserMetadata + def post_room_metadata(user, room_number) + uri = URI("#{SSO_BASE_URL}/v2/users/#{user.oidc_id}/metadata") + body = { metadata: [{ key: 'room', value: Base64.strict_encode64(room_number) }] } + + req = build_request(Net::HTTP::Post, uri, body:) + res = execute_request(user:, req:) + + return if res.is_a?(Net::HTTPSuccess) + + log_failure(user, uri, req, res) + end + + def delete_room_metadata(user) + uri = URI("#{SSO_BASE_URL}/v2/users/#{user.oidc_id}/metadata") + uri.query = URI.encode_www_form([['keys', 'room']]) + + req = build_request(Net::HTTP::Delete, uri) + res = execute_request(user:, req:) + + return if res.is_a?(Net::HTTPSuccess) || res.is_a?(Net::HTTPNotFound) # NotFound => No existing metadata to delete + + log_failure(user, uri, req, res) + end + + def build_request(http_method, uri, body: nil) + req = http_method.new(uri) + req['Authorization'] = "Bearer #{access_token}" + + return req if body.nil? + + req.content_type = 'application/json' + req.body = body.to_json + req + end + + def execute_request(user:, req:) + Net::HTTP.start( + req.uri.hostname, + req.uri.port, + use_ssl: true, + open_timeout: HTTP_OPEN_TIMEOUT_SECONDS, + read_timeout: HTTP_READ_TIMEOUT_SECONDS + ) do |http| + http.request(req) + end + rescue Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error("[SSO] Timeout for user #{user.oidc_id}: #{e.message}") + raise + rescue StandardError => e + Rails.logger.error("[SSO] Error syncing room for user #{user.oidc_id}: #{e.message}") + raise + end + + def log_failure(user, uri, req, res) + Rails.logger.error( + "[SSO] Failed to sync room for user #{user.oidc_id} " \ + "(#{req.method} #{uri}): #{res.code} #{res.body}" + ) + end + + def access_token + Rails.application.credentials.sso_lea5_pat! + end +end diff --git a/app/views/api/users/_user.json.jbuilder b/app/views/api/users/_user.json.jbuilder index 2350bf28..c806add1 100644 --- a/app/views/api/users/_user.json.jbuilder +++ b/app/views/api/users/_user.json.jbuilder @@ -2,6 +2,7 @@ # locals: (json:, user:) -json.extract! user, :id, :firstname, :lastname, :username, :email, :room, :created_at, :updated_at +json.extract! user, :id, :firstname, :lastname, :username, :email, :created_at, :updated_at +json.room user.room&.number json.url api_user_url(user) json.internet_expiration user.internet_expiration diff --git a/app/views/search/_user.html.erb b/app/views/search/_user.html.erb index 435df536..7975b70a 100644 --- a/app/views/search/_user.html.erb +++ b/app/views/search/_user.html.erb @@ -3,7 +3,7 @@
  • <%= link_to user, class: "user" do %> <%= user.firstname %> <%= user.lastname %> - <%= user.room %> + <%= user.room&.number %> <% if user.internet_expiration.nil? || user.internet_expiration < Time.current %> No Internet <% else %> diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index a11627b5..b73643a1 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -20,8 +20,13 @@
    <%= f.label :room %> - <%= f.text_field :room, placeholder: 'A123b or DF1', minlength: 3, maxlength: 5, - pattern: '([A-Fa-f][0-3][0-9]{2}[a-b]?|[Dd][Ff][1-4])' %> + <%= f.text_field :room_number, list: 'rooms-datalist', autocomplete: 'off', + placeholder: 'ex : A105A', value: f.object.room&.number %> + + <% Room.available_for(f.object).order(:number).each do |room| %> + + <% end %> +
    <%= f.submit yield(:button_text) %> diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb index 435df536..7975b70a 100644 --- a/app/views/users/_user.html.erb +++ b/app/views/users/_user.html.erb @@ -3,7 +3,7 @@
  • <%= link_to user, class: "user" do %> <%= user.firstname %> <%= user.lastname %> - <%= user.room %> + <%= user.room&.number %> <% if user.internet_expiration.nil? || user.internet_expiration < Time.current %> No Internet <% else %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index e947ce04..1834b90a 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -34,7 +34,7 @@ <% else %> No Internet <% end %> - <%= @user.room %> + <%= @user.room&.number %> <%= pluralize(@machines.size, "machine") %> <% if current_user == @user %>
    diff --git a/db/migrate/20260307123053_create_rooms.rb b/db/migrate/20260307123053_create_rooms.rb new file mode 100644 index 00000000..851d7ce0 --- /dev/null +++ b/db/migrate/20260307123053_create_rooms.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CreateRooms < ActiveRecord::Migration[7.2] + def change + create_table :rooms do |t| + t.string :number, limit: 6, null: false + t.string :group, limit: 6, null: false + t.string :building, limit: 1, null: false + t.integer :floor, null: false + t.references :user, foreign_key: true, index: { unique: true, where: 'user_id IS NOT NULL' } + + t.timestamps + end + add_index :rooms, :number, unique: true + add_index :rooms, :group + add_index :rooms, [:building, :floor] + + # Migrate existing user.room data to rooms.user_id + reversible do |dir| + dir.up do + execute <<~SQL.squish + UPDATE rooms SET user_id = users.id FROM users WHERE users.room = rooms.number + SQL + end + end + + # Remove the old room column from users + remove_index :users, :room, name: 'index_users_on_room' + remove_column :users, :room, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 8a776707..5254b170 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[7.2].define(version: 2025_12_14_175208) do +ActiveRecord::Schema[7.2].define(version: 2026_03_07_123053) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -85,10 +85,10 @@ end create_table "invoices", force: :cascade do |t| - t.bigint "number", null: false t.jsonb "generation_json", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "number", null: false t.index ["number"], name: "index_invoices_on_number", unique: true end @@ -142,6 +142,18 @@ t.index ["subscription_offer_id"], name: "index_refunds_subscription_offers_on_subscription_offer_id" end + create_table "rooms", force: :cascade do |t| + t.string "number", limit: 6, null: false + t.string "group", limit: 6, null: false + t.string "building", limit: 1, null: false + t.integer "floor", null: false + t.bigint "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["number"], name: "index_rooms_on_number", unique: true + t.index ["user_id"], name: "index_rooms_on_user_id", unique: true, where: "(user_id IS NOT NULL)" + end + create_table "sales", force: :cascade do |t| t.bigint "seller_id" t.bigint "client_id", null: false @@ -195,7 +207,6 @@ t.string "firstname", null: false t.string "lastname", null: false t.string "email", null: false - t.string "room" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "oidc_id" @@ -203,7 +214,6 @@ t.string "username", null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["oidc_id"], name: "index_users_on_oidc_id", unique: true - t.index ["room"], name: "index_users_on_room", unique: true, where: "(room IS NOT NULL)" t.index ["username"], name: "index_users_on_username", unique: true t.index ["wifi_password"], name: "index_users_on_wifi_password" end @@ -223,6 +233,7 @@ add_foreign_key "refunds", "users", column: "refunder_id" add_foreign_key "refunds_subscription_offers", "refunds" add_foreign_key "refunds_subscription_offers", "subscription_offers" + add_foreign_key "rooms", "users" add_foreign_key "sales", "invoices" add_foreign_key "sales", "payment_methods" add_foreign_key "sales", "users", column: "client_id" diff --git a/db/seeds.rb b/db/seeds.rb index ba9afb7f..473ac45b 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -11,11 +11,21 @@ # end require_relative 'seeds/ips' +require_relative 'seeds/rooms' -User.create([{ firstname: 'Paul', lastname: 'Marcel', room: 'A123', email: 'paul.marcel@gmail.com', - username: 'paulmarcel' }, - { firstname: 'Gérard', lastname: 'Dupont', room: 'd145', email: 'xXgerardDUPONTXx@gmail.com', - username: 'gerarddupont' }]) +paul = User.find_or_initialize_by(email: 'paul.marcel@gmail.com') +paul.update!(firstname: 'Paul', lastname: 'Marcel', username: 'paulmarcel') +Room.find_by(number: 'A109A')&.update!(user: paul) -User.first.machines.create({ mac: 'AA:AA:AA:AA:AA:AA', name: 'Powerful-Battery', ip: Ip.first }) -User.first.machines.create({ mac: 'AA:AA:AA:AA:AA:AB', name: 'Powerful-Battery-2', ip: Ip.last }) +gerard = User.find_or_initialize_by(email: 'xXgerardDUPONTXx@gmail.com') +gerard.update!(firstname: 'Gérard', lastname: 'Dupont', username: 'gerarddupont') +Room.find_by(number: 'A201')&.update!(user: gerard) + +paul.machines.find_or_create_by!(mac: 'AA:AA:AA:AA:AA:AA') do |machine| + machine.name = 'Powerful-Battery' + machine.ip = Ip.first +end +paul.machines.find_or_create_by!(mac: 'AA:AA:AA:AA:AA:AB') do |machine| + machine.name = 'Powerful-Battery-2' + machine.ip = Ip.last +end diff --git a/db/seeds/rooms.rb b/db/seeds/rooms.rb new file mode 100644 index 00000000..47c029dc --- /dev/null +++ b/db/seeds/rooms.rb @@ -0,0 +1,667 @@ +# frozen_string_literal: true + +# Seed rooms for the residence. +# Rooms follow the pattern: Building (A-F) + Floor (0-3) + Number + optional letter (for shared apartments) +# Special rooms: DF1-DF4, CLAP, ALUMNI, AGR1, AGR2 + +# rubocop: disable Metrics/CollectionLiteralLength +rooms = [ + # Building A - Floor 0 + { number: 'A001', group: 'A001', building: 'A', floor: 0 }, + { number: 'A002', group: 'A002', building: 'A', floor: 0 }, + { number: 'A003', group: 'A003', building: 'A', floor: 0 }, + { number: 'A004A', group: 'A004', building: 'A', floor: 0 }, + { number: 'A004B', group: 'A004', building: 'A', floor: 0 }, + { number: 'A005', group: 'A005', building: 'A', floor: 0 }, + { number: 'A006', group: 'A006', building: 'A', floor: 0 }, + { number: 'A007', group: 'A007', building: 'A', floor: 0 }, + { number: 'A008A', group: 'A008', building: 'A', floor: 0 }, + { number: 'A008B', group: 'A008', building: 'A', floor: 0 }, + { number: 'A009A', group: 'A009', building: 'A', floor: 0 }, + { number: 'A009B', group: 'A009', building: 'A', floor: 0 }, + { number: 'A010A', group: 'A010', building: 'A', floor: 0 }, + { number: 'A010B', group: 'A010', building: 'A', floor: 0 }, + + # Building A - Floor 1 + { number: 'A101', group: 'A101', building: 'A', floor: 1 }, + { number: 'A102', group: 'A102', building: 'A', floor: 1 }, + { number: 'A103', group: 'A103', building: 'A', floor: 1 }, + { number: 'A104A', group: 'A104', building: 'A', floor: 1 }, + { number: 'A104B', group: 'A104', building: 'A', floor: 1 }, + { number: 'A105A', group: 'A105', building: 'A', floor: 1 }, + { number: 'A105B', group: 'A105', building: 'A', floor: 1 }, + { number: 'A106', group: 'A106', building: 'A', floor: 1 }, + { number: 'A107', group: 'A107', building: 'A', floor: 1 }, + { number: 'A108A', group: 'A108', building: 'A', floor: 1 }, + { number: 'A108B', group: 'A108', building: 'A', floor: 1 }, + { number: 'A109A', group: 'A109', building: 'A', floor: 1 }, + { number: 'A109B', group: 'A109', building: 'A', floor: 1 }, + { number: 'A110A', group: 'A110', building: 'A', floor: 1 }, + { number: 'A110B', group: 'A110', building: 'A', floor: 1 }, + { number: 'A111', group: 'A111', building: 'A', floor: 1 }, + { number: 'A112', group: 'A112', building: 'A', floor: 1 }, + { number: 'A113', group: 'A113', building: 'A', floor: 1 }, + # Room A114 - A118 are missing. Do they exist? Is A119 a typo? + { number: 'A119', group: 'A119', building: 'A', floor: 1 }, + + # Building A - Floor 2 + { number: 'A201', group: 'A201', building: 'A', floor: 2 }, + { number: 'A202', group: 'A202', building: 'A', floor: 2 }, + { number: 'A203', group: 'A203', building: 'A', floor: 2 }, + { number: 'A204A', group: 'A204', building: 'A', floor: 2 }, + { number: 'A204B', group: 'A204', building: 'A', floor: 2 }, + { number: 'A205A', group: 'A205', building: 'A', floor: 2 }, + { number: 'A205B', group: 'A205', building: 'A', floor: 2 }, + { number: 'A206', group: 'A206', building: 'A', floor: 2 }, + { number: 'A207', group: 'A207', building: 'A', floor: 2 }, + { number: 'A208A', group: 'A208', building: 'A', floor: 2 }, + { number: 'A208B', group: 'A208', building: 'A', floor: 2 }, + { number: 'A209A', group: 'A209', building: 'A', floor: 2 }, + { number: 'A209B', group: 'A209', building: 'A', floor: 2 }, + { number: 'A210A', group: 'A210', building: 'A', floor: 2 }, + { number: 'A210B', group: 'A210', building: 'A', floor: 2 }, + { number: 'A211', group: 'A211', building: 'A', floor: 2 }, + { number: 'A212', group: 'A212', building: 'A', floor: 2 }, + { number: 'A213', group: 'A213', building: 'A', floor: 2 }, + + # Building A - Floor 3 + { number: 'A301', group: 'A301', building: 'A', floor: 3 }, + { number: 'A302', group: 'A302', building: 'A', floor: 3 }, + { number: 'A303', group: 'A303', building: 'A', floor: 3 }, + { number: 'A304A', group: 'A304', building: 'A', floor: 3 }, + { number: 'A304B', group: 'A304', building: 'A', floor: 3 }, + { number: 'A305', group: 'A305', building: 'A', floor: 3 }, + { number: 'A306', group: 'A306', building: 'A', floor: 3 }, + { number: 'A307', group: 'A307', building: 'A', floor: 3 }, + { number: 'A308A', group: 'A308', building: 'A', floor: 3 }, + { number: 'A308B', group: 'A308', building: 'A', floor: 3 }, + { number: 'A309A', group: 'A309', building: 'A', floor: 3 }, + { number: 'A309B', group: 'A309', building: 'A', floor: 3 }, + { number: 'A310A', group: 'A310', building: 'A', floor: 3 }, + { number: 'A310B', group: 'A310', building: 'A', floor: 3 }, + { number: 'A311', group: 'A311', building: 'A', floor: 3 }, + { number: 'A312', group: 'A312', building: 'A', floor: 3 }, + { number: 'A313', group: 'A313', building: 'A', floor: 3 }, + # Room A314 and A315 are missing. Do they exist? Is A316 a typo? + { number: 'A316', group: 'A316', building: 'A', floor: 3 }, + + # Building B - Floor 0 + { number: 'B001A', group: 'B001', building: 'B', floor: 0 }, + { number: 'B001B', group: 'B001', building: 'B', floor: 0 }, + { number: 'B002', group: 'B002', building: 'B', floor: 0 }, + { number: 'B003', group: 'B003', building: 'B', floor: 0 }, + { number: 'B004', group: 'B004', building: 'B', floor: 0 }, + # Room B005 - B009 are missing. Do they exist? Is B010 a typo? + { number: 'B010', group: 'B010', building: 'B', floor: 0 }, + + # Building B - Floor 1 + { number: 'B101', group: 'B101', building: 'B', floor: 1 }, + { number: 'B102', group: 'B102', building: 'B', floor: 1 }, + { number: 'B103', group: 'B103', building: 'B', floor: 1 }, + { number: 'B104', group: 'B104', building: 'B', floor: 1 }, + { number: 'B105A', group: 'B105', building: 'B', floor: 1 }, + { number: 'B105B', group: 'B105', building: 'B', floor: 1 }, + { number: 'B106', group: 'B106', building: 'B', floor: 1 }, + { number: 'B107', group: 'B107', building: 'B', floor: 1 }, + { number: 'B108', group: 'B108', building: 'B', floor: 1 }, + { number: 'B109', group: 'B109', building: 'B', floor: 1 }, + { number: 'B110A', group: 'B110', building: 'B', floor: 1 }, + { number: 'B110B', group: 'B110', building: 'B', floor: 1 }, + { number: 'B111A', group: 'B111', building: 'B', floor: 1 }, + { number: 'B111B', group: 'B111', building: 'B', floor: 1 }, + { number: 'B112', group: 'B112', building: 'B', floor: 1 }, + { number: 'B113', group: 'B113', building: 'B', floor: 1 }, + { number: 'B114', group: 'B114', building: 'B', floor: 1 }, + + # Building B - Floor 2 + { number: 'B201', group: 'B201', building: 'B', floor: 2 }, + { number: 'B202', group: 'B202', building: 'B', floor: 2 }, + { number: 'B203', group: 'B203', building: 'B', floor: 2 }, + { number: 'B204', group: 'B204', building: 'B', floor: 2 }, + { number: 'B205A', group: 'B205', building: 'B', floor: 2 }, + { number: 'B205B', group: 'B205', building: 'B', floor: 2 }, + { number: 'B206', group: 'B206', building: 'B', floor: 2 }, + { number: 'B207', group: 'B207', building: 'B', floor: 2 }, + { number: 'B208', group: 'B208', building: 'B', floor: 2 }, + { number: 'B209', group: 'B209', building: 'B', floor: 2 }, + { number: 'B210A', group: 'B210', building: 'B', floor: 2 }, + { number: 'B210B', group: 'B210', building: 'B', floor: 2 }, + { number: 'B211A', group: 'B211', building: 'B', floor: 2 }, + { number: 'B211B', group: 'B211', building: 'B', floor: 2 }, + { number: 'B212', group: 'B212', building: 'B', floor: 2 }, + { number: 'B213', group: 'B213', building: 'B', floor: 2 }, + { number: 'B214', group: 'B214', building: 'B', floor: 2 }, + + # Building B - Floor 3 + { number: 'B301', group: 'B301', building: 'B', floor: 3 }, + { number: 'B302', group: 'B302', building: 'B', floor: 3 }, + { number: 'B303A', group: 'B303', building: 'B', floor: 3 }, + { number: 'B303B', group: 'B303', building: 'B', floor: 3 }, + { number: 'B304', group: 'B304', building: 'B', floor: 3 }, + { number: 'B305', group: 'B305', building: 'B', floor: 3 }, + { number: 'B306', group: 'B306', building: 'B', floor: 3 }, + { number: 'B307', group: 'B307', building: 'B', floor: 3 }, + { number: 'B308A', group: 'B308', building: 'B', floor: 3 }, + { number: 'B308B', group: 'B308', building: 'B', floor: 3 }, + { number: 'B309A', group: 'B309', building: 'B', floor: 3 }, + { number: 'B309B', group: 'B309', building: 'B', floor: 3 }, + { number: 'B310', group: 'B310', building: 'B', floor: 3 }, + { number: 'B311', group: 'B311', building: 'B', floor: 3 }, + { number: 'B312', group: 'B312', building: 'B', floor: 3 }, + + # Building C - Floor 0 + { number: 'C001A', group: 'C001', building: 'C', floor: 0 }, + { number: 'C001B', group: 'C001', building: 'C', floor: 0 }, + { number: 'C002', group: 'C002', building: 'C', floor: 0 }, + { number: 'C003A', group: 'C003', building: 'C', floor: 0 }, + { number: 'C003B', group: 'C003', building: 'C', floor: 0 }, + { number: 'C004', group: 'C004', building: 'C', floor: 0 }, + { number: 'C005', group: 'C005', building: 'C', floor: 0 }, + { number: 'C006', group: 'C006', building: 'C', floor: 0 }, + { number: 'C007', group: 'C007', building: 'C', floor: 0 }, + { number: 'C008', group: 'C008', building: 'C', floor: 0 }, + { number: 'C009A', group: 'C009', building: 'C', floor: 0 }, + { number: 'C009B', group: 'C009', building: 'C', floor: 0 }, + { number: 'C010', group: 'C010', building: 'C', floor: 0 }, + { number: 'C011', group: 'C011', building: 'C', floor: 0 }, + { number: 'C012', group: 'C012', building: 'C', floor: 0 }, + { number: 'C013', group: 'C013', building: 'C', floor: 0 }, + + # Building C - Floor 1 + { number: 'C101', group: 'C101', building: 'C', floor: 1 }, + { number: 'C102', group: 'C102', building: 'C', floor: 1 }, + { number: 'C103A', group: 'C103', building: 'C', floor: 1 }, + { number: 'C103B', group: 'C103', building: 'C', floor: 1 }, + { number: 'C104', group: 'C104', building: 'C', floor: 1 }, + { number: 'C105A', group: 'C105', building: 'C', floor: 1 }, + { number: 'C105B', group: 'C105', building: 'C', floor: 1 }, + { number: 'C106', group: 'C106', building: 'C', floor: 1 }, + { number: 'C107', group: 'C107', building: 'C', floor: 1 }, + { number: 'C108', group: 'C108', building: 'C', floor: 1 }, + { number: 'C109', group: 'C109', building: 'C', floor: 1 }, + { number: 'C110', group: 'C110', building: 'C', floor: 1 }, + { number: 'C111A', group: 'C111', building: 'C', floor: 1 }, + { number: 'C111B', group: 'C111', building: 'C', floor: 1 }, + { number: 'C112A', group: 'C112', building: 'C', floor: 1 }, + { number: 'C112B', group: 'C112', building: 'C', floor: 1 }, + { number: 'C113', group: 'C113', building: 'C', floor: 1 }, + { number: 'C114', group: 'C114', building: 'C', floor: 1 }, + { number: 'C115', group: 'C115', building: 'C', floor: 1 }, + # Room C116 - C120 are missing. Do they exist? Is C121 a typo? + { number: 'C121A', group: 'C121', building: 'C', floor: 1 }, + { number: 'C121B', group: 'C121', building: 'C', floor: 1 }, + + # Building C - Floor 2 + { number: 'C201', group: 'C201', building: 'C', floor: 2 }, + { number: 'C202', group: 'C202', building: 'C', floor: 2 }, + { number: 'C203A', group: 'C203', building: 'C', floor: 2 }, + { number: 'C203B', group: 'C203', building: 'C', floor: 2 }, + { number: 'C204', group: 'C204', building: 'C', floor: 2 }, + { number: 'C205A', group: 'C205', building: 'C', floor: 2 }, + { number: 'C205B', group: 'C205', building: 'C', floor: 2 }, + { number: 'C206', group: 'C206', building: 'C', floor: 2 }, + { number: 'C207', group: 'C207', building: 'C', floor: 2 }, + { number: 'C208', group: 'C208', building: 'C', floor: 2 }, + { number: 'C209', group: 'C209', building: 'C', floor: 2 }, + { number: 'C210', group: 'C210', building: 'C', floor: 2 }, + { number: 'C211A', group: 'C211', building: 'C', floor: 2 }, + { number: 'C211B', group: 'C211', building: 'C', floor: 2 }, + { number: 'C212A', group: 'C212', building: 'C', floor: 2 }, + { number: 'C212B', group: 'C212', building: 'C', floor: 2 }, + { number: 'C213', group: 'C213', building: 'C', floor: 2 }, + { number: 'C214', group: 'C214', building: 'C', floor: 2 }, + { number: 'C215', group: 'C215', building: 'C', floor: 2 }, + + # Building C - Floor 3 + { number: 'C301', group: 'C301', building: 'C', floor: 3 }, + { number: 'C302', group: 'C302', building: 'C', floor: 3 }, + { number: 'C303A', group: 'C303', building: 'C', floor: 3 }, + { number: 'C303B', group: 'C303', building: 'C', floor: 3 }, + { number: 'C304A', group: 'C304', building: 'C', floor: 3 }, + { number: 'C304B', group: 'C304', building: 'C', floor: 3 }, + { number: 'C305', group: 'C305', building: 'C', floor: 3 }, + { number: 'C306', group: 'C306', building: 'C', floor: 3 }, + { number: 'C307', group: 'C307', building: 'C', floor: 3 }, + { number: 'C308', group: 'C308', building: 'C', floor: 3 }, + { number: 'C309', group: 'C309', building: 'C', floor: 3 }, + { number: 'C310A', group: 'C310', building: 'C', floor: 3 }, + { number: 'C310B', group: 'C310', building: 'C', floor: 3 }, + { number: 'C311', group: 'C311', building: 'C', floor: 3 }, + { number: 'C312', group: 'C312', building: 'C', floor: 3 }, + { number: 'C313', group: 'C313', building: 'C', floor: 3 }, + { number: 'C314', group: 'C314', building: 'C', floor: 3 }, + + # Building D - Floor 0 + { number: 'D001', group: 'D001', building: 'D', floor: 0 }, + { number: 'D002', group: 'D002', building: 'D', floor: 0 }, + { number: 'D003', group: 'D003', building: 'D', floor: 0 }, + { number: 'D004', group: 'D004', building: 'D', floor: 0 }, + { number: 'D005', group: 'D005', building: 'D', floor: 0 }, + { number: 'D006', group: 'D006', building: 'D', floor: 0 }, + { number: 'D007A', group: 'D007', building: 'D', floor: 0 }, + { number: 'D007B', group: 'D007', building: 'D', floor: 0 }, + { number: 'D008A', group: 'D008', building: 'D', floor: 0 }, + { number: 'D008B', group: 'D008', building: 'D', floor: 0 }, + { number: 'D009', group: 'D009', building: 'D', floor: 0 }, + { number: 'D010', group: 'D010', building: 'D', floor: 0 }, + { number: 'D011', group: 'D011', building: 'D', floor: 0 }, + + # Building D - Floor 1 + { number: 'D101', group: 'D101', building: 'D', floor: 1 }, + { number: 'D102', group: 'D102', building: 'D', floor: 1 }, + { number: 'D103', group: 'D103', building: 'D', floor: 1 }, + { number: 'D104', group: 'D104', building: 'D', floor: 1 }, + { number: 'D105', group: 'D105', building: 'D', floor: 1 }, + { number: 'D106', group: 'D106', building: 'D', floor: 1 }, + { number: 'D107A', group: 'D107', building: 'D', floor: 1 }, + { number: 'D107B', group: 'D107', building: 'D', floor: 1 }, + { number: 'D108A', group: 'D108', building: 'D', floor: 1 }, + { number: 'D108B', group: 'D108', building: 'D', floor: 1 }, + { number: 'D109', group: 'D109', building: 'D', floor: 1 }, + { number: 'D110', group: 'D110', building: 'D', floor: 1 }, + { number: 'D111A', group: 'D111', building: 'D', floor: 1 }, + { number: 'D111B', group: 'D111', building: 'D', floor: 1 }, + { number: 'D112', group: 'D112', building: 'D', floor: 1 }, + { number: 'D113', group: 'D113', building: 'D', floor: 1 }, + { number: 'D114', group: 'D114', building: 'D', floor: 1 }, + { number: 'D115', group: 'D115', building: 'D', floor: 1 }, + { number: 'D116A', group: 'D116', building: 'D', floor: 1 }, + { number: 'D116B', group: 'D116', building: 'D', floor: 1 }, + { number: 'D117A', group: 'D117', building: 'D', floor: 1 }, + { number: 'D117B', group: 'D117', building: 'D', floor: 1 }, + { number: 'D118', group: 'D118', building: 'D', floor: 1 }, + { number: 'D119', group: 'D119', building: 'D', floor: 1 }, + { number: 'D120', group: 'D120', building: 'D', floor: 1 }, + { number: 'D121A', group: 'D121', building: 'D', floor: 1 }, + { number: 'D121B', group: 'D121', building: 'D', floor: 1 }, + { number: 'D122A', group: 'D122', building: 'D', floor: 1 }, + { number: 'D122B', group: 'D122', building: 'D', floor: 1 }, + { number: 'D123', group: 'D123', building: 'D', floor: 1 }, + { number: 'D124', group: 'D124', building: 'D', floor: 1 }, + + # Building D - Floor 2 + { number: 'D201', group: 'D201', building: 'D', floor: 2 }, + { number: 'D202', group: 'D202', building: 'D', floor: 2 }, + { number: 'D203', group: 'D203', building: 'D', floor: 2 }, + { number: 'D204', group: 'D204', building: 'D', floor: 2 }, + { number: 'D205', group: 'D205', building: 'D', floor: 2 }, + { number: 'D206', group: 'D206', building: 'D', floor: 2 }, + { number: 'D207A', group: 'D207', building: 'D', floor: 2 }, + { number: 'D207B', group: 'D207', building: 'D', floor: 2 }, + { number: 'D208A', group: 'D208', building: 'D', floor: 2 }, + { number: 'D208B', group: 'D208', building: 'D', floor: 2 }, + { number: 'D209', group: 'D209', building: 'D', floor: 2 }, + { number: 'D210', group: 'D210', building: 'D', floor: 2 }, + { number: 'D211A', group: 'D211', building: 'D', floor: 2 }, + { number: 'D211B', group: 'D211', building: 'D', floor: 2 }, + { number: 'D212', group: 'D212', building: 'D', floor: 2 }, + { number: 'D213', group: 'D213', building: 'D', floor: 2 }, + { number: 'D214', group: 'D214', building: 'D', floor: 2 }, + { number: 'D215', group: 'D215', building: 'D', floor: 2 }, + { number: 'D216A', group: 'D216', building: 'D', floor: 2 }, + { number: 'D216B', group: 'D216', building: 'D', floor: 2 }, + { number: 'D217A', group: 'D217', building: 'D', floor: 2 }, + { number: 'D217B', group: 'D217', building: 'D', floor: 2 }, + { number: 'D218', group: 'D218', building: 'D', floor: 2 }, + { number: 'D219', group: 'D219', building: 'D', floor: 2 }, + { number: 'D220', group: 'D220', building: 'D', floor: 2 }, + { number: 'D221A', group: 'D221', building: 'D', floor: 2 }, + { number: 'D221B', group: 'D221', building: 'D', floor: 2 }, + { number: 'D222A', group: 'D222', building: 'D', floor: 2 }, + { number: 'D222B', group: 'D222', building: 'D', floor: 2 }, + { number: 'D223', group: 'D223', building: 'D', floor: 2 }, + { number: 'D224', group: 'D224', building: 'D', floor: 2 }, + + # Building D - Floor 3 + { number: 'D301', group: 'D301', building: 'D', floor: 3 }, + { number: 'D302', group: 'D302', building: 'D', floor: 3 }, + { number: 'D303', group: 'D303', building: 'D', floor: 3 }, + { number: 'D304', group: 'D304', building: 'D', floor: 3 }, + { number: 'D305', group: 'D305', building: 'D', floor: 3 }, + { number: 'D306', group: 'D306', building: 'D', floor: 3 }, + { number: 'D307A', group: 'D307', building: 'D', floor: 3 }, + { number: 'D307B', group: 'D307', building: 'D', floor: 3 }, + { number: 'D308', group: 'D308', building: 'D', floor: 3 }, + { number: 'D309', group: 'D309', building: 'D', floor: 3 }, + { number: 'D310A', group: 'D310', building: 'D', floor: 3 }, + { number: 'D310B', group: 'D310', building: 'D', floor: 3 }, + { number: 'D311', group: 'D311', building: 'D', floor: 3 }, + { number: 'D312', group: 'D312', building: 'D', floor: 3 }, + { number: 'D313', group: 'D313', building: 'D', floor: 3 }, + { number: 'D314', group: 'D314', building: 'D', floor: 3 }, + { number: 'D315A', group: 'D315', building: 'D', floor: 3 }, + { number: 'D315B', group: 'D315', building: 'D', floor: 3 }, + { number: 'D316A', group: 'D316', building: 'D', floor: 3 }, + { number: 'D316B', group: 'D316', building: 'D', floor: 3 }, + { number: 'D317', group: 'D317', building: 'D', floor: 3 }, + { number: 'D318', group: 'D318', building: 'D', floor: 3 }, + { number: 'D319', group: 'D319', building: 'D', floor: 3 }, + { number: 'D320A', group: 'D320', building: 'D', floor: 3 }, + { number: 'D320B', group: 'D320', building: 'D', floor: 3 }, + { number: 'D321', group: 'D321', building: 'D', floor: 3 }, + { number: 'D322', group: 'D322', building: 'D', floor: 3 }, + { number: 'D323', group: 'D323', building: 'D', floor: 3 }, + + # Building E - Floor 0 + { number: 'E001', group: 'E001', building: 'E', floor: 0 }, + { number: 'E002', group: 'E002', building: 'E', floor: 0 }, + { number: 'E003', group: 'E003', building: 'E', floor: 0 }, + { number: 'E004', group: 'E004', building: 'E', floor: 0 }, + { number: 'E005', group: 'E005', building: 'E', floor: 0 }, + { number: 'E006', group: 'E006', building: 'E', floor: 0 }, + { number: 'E007', group: 'E007', building: 'E', floor: 0 }, + { number: 'E008', group: 'E008', building: 'E', floor: 0 }, + { number: 'E009', group: 'E009', building: 'E', floor: 0 }, + { number: 'E010', group: 'E010', building: 'E', floor: 0 }, + { number: 'E011A', group: 'E011', building: 'E', floor: 0 }, + { number: 'E011B', group: 'E011', building: 'E', floor: 0 }, + { number: 'E012', group: 'E012', building: 'E', floor: 0 }, + { number: 'E013', group: 'E013', building: 'E', floor: 0 }, + { number: 'E014A', group: 'E014', building: 'E', floor: 0 }, + { number: 'E014B', group: 'E014', building: 'E', floor: 0 }, + { number: 'E015', group: 'E015', building: 'E', floor: 0 }, + { number: 'E016', group: 'E016', building: 'E', floor: 0 }, + { number: 'E017', group: 'E017', building: 'E', floor: 0 }, + { number: 'E018', group: 'E018', building: 'E', floor: 0 }, + { number: 'E019', group: 'E019', building: 'E', floor: 0 }, + { number: 'E020', group: 'E020', building: 'E', floor: 0 }, + { number: 'E021', group: 'E021', building: 'E', floor: 0 }, + { number: 'E022', group: 'E022', building: 'E', floor: 0 }, + + # Building E - Floor 1 + { number: 'E101', group: 'E101', building: 'E', floor: 1 }, + { number: 'E102', group: 'E102', building: 'E', floor: 1 }, + { number: 'E103', group: 'E103', building: 'E', floor: 1 }, + { number: 'E104A', group: 'E104', building: 'E', floor: 1 }, + { number: 'E104B', group: 'E104', building: 'E', floor: 1 }, + { number: 'E105', group: 'E105', building: 'E', floor: 1 }, + { number: 'E106', group: 'E106', building: 'E', floor: 1 }, + { number: 'E107', group: 'E107', building: 'E', floor: 1 }, + { number: 'E108', group: 'E108', building: 'E', floor: 1 }, + { number: 'E109', group: 'E109', building: 'E', floor: 1 }, + { number: 'E110', group: 'E110', building: 'E', floor: 1 }, + { number: 'E111', group: 'E111', building: 'E', floor: 1 }, + { number: 'E112', group: 'E112', building: 'E', floor: 1 }, + { number: 'E113', group: 'E113', building: 'E', floor: 1 }, + { number: 'E114A', group: 'E114', building: 'E', floor: 1 }, + { number: 'E114B', group: 'E114', building: 'E', floor: 1 }, + { number: 'E115A', group: 'E115', building: 'E', floor: 1 }, + { number: 'E115B', group: 'E115', building: 'E', floor: 1 }, + { number: 'E116', group: 'E116', building: 'E', floor: 1 }, + { number: 'E117', group: 'E117', building: 'E', floor: 1 }, + { number: 'E118A', group: 'E118', building: 'E', floor: 1 }, + { number: 'E118B', group: 'E118', building: 'E', floor: 1 }, + { number: 'E119A', group: 'E119', building: 'E', floor: 1 }, + { number: 'E119B', group: 'E119', building: 'E', floor: 1 }, + { number: 'E120', group: 'E120', building: 'E', floor: 1 }, + { number: 'E121', group: 'E121', building: 'E', floor: 1 }, + { number: 'E122', group: 'E122', building: 'E', floor: 1 }, + { number: 'E123', group: 'E123', building: 'E', floor: 1 }, + { number: 'E124', group: 'E124', building: 'E', floor: 1 }, + { number: 'E125', group: 'E125', building: 'E', floor: 1 }, + { number: 'E126', group: 'E126', building: 'E', floor: 1 }, + # Room E127 - E138 are missing. Do they exist? Is E139 a typo? + { number: 'E139A', group: 'E139', building: 'E', floor: 1 }, + { number: 'E139B', group: 'E139', building: 'E', floor: 1 }, + + # Building E - Floor 2 + { number: 'E201', group: 'E201', building: 'E', floor: 2 }, + { number: 'E202', group: 'E202', building: 'E', floor: 2 }, + { number: 'E203', group: 'E203', building: 'E', floor: 2 }, + { number: 'E204', group: 'E204', building: 'E', floor: 2 }, + { number: 'E205', group: 'E205', building: 'E', floor: 2 }, + { number: 'E206', group: 'E206', building: 'E', floor: 2 }, + { number: 'E207', group: 'E207', building: 'E', floor: 2 }, + { number: 'E208', group: 'E208', building: 'E', floor: 2 }, + { number: 'E209', group: 'E209', building: 'E', floor: 2 }, + { number: 'E210', group: 'E210', building: 'E', floor: 2 }, + { number: 'E211', group: 'E211', building: 'E', floor: 2 }, + { number: 'E212', group: 'E212', building: 'E', floor: 2 }, + { number: 'E213', group: 'E213', building: 'E', floor: 2 }, + { number: 'E214A', group: 'E214', building: 'E', floor: 2 }, + { number: 'E214B', group: 'E214', building: 'E', floor: 2 }, + { number: 'E215A', group: 'E215', building: 'E', floor: 2 }, + { number: 'E215B', group: 'E215', building: 'E', floor: 2 }, + { number: 'E216', group: 'E216', building: 'E', floor: 2 }, + { number: 'E217', group: 'E217', building: 'E', floor: 2 }, + { number: 'E218A', group: 'E218', building: 'E', floor: 2 }, + { number: 'E218B', group: 'E218', building: 'E', floor: 2 }, + { number: 'E219A', group: 'E219', building: 'E', floor: 2 }, + { number: 'E219B', group: 'E219', building: 'E', floor: 2 }, + { number: 'E220', group: 'E220', building: 'E', floor: 2 }, + { number: 'E221', group: 'E221', building: 'E', floor: 2 }, + { number: 'E222', group: 'E222', building: 'E', floor: 2 }, + { number: 'E223', group: 'E223', building: 'E', floor: 2 }, + { number: 'E224', group: 'E224', building: 'E', floor: 2 }, + { number: 'E225', group: 'E225', building: 'E', floor: 2 }, + { number: 'E226', group: 'E226', building: 'E', floor: 2 }, + + # Building E - Floor 3 + { number: 'E301', group: 'E301', building: 'E', floor: 3 }, + { number: 'E302', group: 'E302', building: 'E', floor: 3 }, + { number: 'E303', group: 'E303', building: 'E', floor: 3 }, + { number: 'E304', group: 'E304', building: 'E', floor: 3 }, + { number: 'E305', group: 'E305', building: 'E', floor: 3 }, + { number: 'E306', group: 'E306', building: 'E', floor: 3 }, + { number: 'E307', group: 'E307', building: 'E', floor: 3 }, + { number: 'E308', group: 'E308', building: 'E', floor: 3 }, + { number: 'E309', group: 'E309', building: 'E', floor: 3 }, + { number: 'E310', group: 'E310', building: 'E', floor: 3 }, + { number: 'E311', group: 'E311', building: 'E', floor: 3 }, + { number: 'E312', group: 'E312', building: 'E', floor: 3 }, + { number: 'E313', group: 'E313', building: 'E', floor: 3 }, + { number: 'E314', group: 'E314', building: 'E', floor: 3 }, + { number: 'E315A', group: 'E315', building: 'E', floor: 3 }, + { number: 'E315B', group: 'E315', building: 'E', floor: 3 }, + { number: 'E316', group: 'E316', building: 'E', floor: 3 }, + { number: 'E317', group: 'E317', building: 'E', floor: 3 }, + { number: 'E318A', group: 'E318', building: 'E', floor: 3 }, + { number: 'E318B', group: 'E318', building: 'E', floor: 3 }, + { number: 'E319', group: 'E319', building: 'E', floor: 3 }, + { number: 'E320', group: 'E320', building: 'E', floor: 3 }, + { number: 'E321', group: 'E321', building: 'E', floor: 3 }, + { number: 'E322', group: 'E322', building: 'E', floor: 3 }, + { number: 'E323', group: 'E323', building: 'E', floor: 3 }, + { number: 'E324', group: 'E324', building: 'E', floor: 3 }, + { number: 'E325', group: 'E325', building: 'E', floor: 3 }, + { number: 'E326', group: 'E326', building: 'E', floor: 3 }, + + # Building F - Floor 0 + { number: 'F001', group: 'F001', building: 'F', floor: 0 }, + { number: 'F002', group: 'F002', building: 'F', floor: 0 }, + { number: 'F003', group: 'F003', building: 'F', floor: 0 }, + { number: 'F004A', group: 'F004', building: 'F', floor: 0 }, + { number: 'F004B', group: 'F004', building: 'F', floor: 0 }, + { number: 'F005', group: 'F005', building: 'F', floor: 0 }, + { number: 'F006', group: 'F006', building: 'F', floor: 0 }, + { number: 'F007A', group: 'F007', building: 'F', floor: 0 }, + { number: 'F007B', group: 'F007', building: 'F', floor: 0 }, + { number: 'F008', group: 'F008', building: 'F', floor: 0 }, + { number: 'F009', group: 'F009', building: 'F', floor: 0 }, + { number: 'F010', group: 'F010', building: 'F', floor: 0 }, + { number: 'F011', group: 'F011', building: 'F', floor: 0 }, + { number: 'F012A', group: 'F012', building: 'F', floor: 0 }, + { number: 'F012B', group: 'F012', building: 'F', floor: 0 }, + { number: 'F013A', group: 'F013', building: 'F', floor: 0 }, + { number: 'F013B', group: 'F013', building: 'F', floor: 0 }, + { number: 'F014', group: 'F014', building: 'F', floor: 0 }, + { number: 'F015', group: 'F015', building: 'F', floor: 0 }, + { number: 'F016', group: 'F016', building: 'F', floor: 0 }, + { number: 'F017', group: 'F017', building: 'F', floor: 0 }, + { number: 'F018A', group: 'F018', building: 'F', floor: 0 }, + { number: 'F018B', group: 'F018', building: 'F', floor: 0 }, + { number: 'F019', group: 'F019', building: 'F', floor: 0 }, + { number: 'F020', group: 'F020', building: 'F', floor: 0 }, + { number: 'F021', group: 'F021', building: 'F', floor: 0 }, + { number: 'F022', group: 'F022', building: 'F', floor: 0 }, + { number: 'F023', group: 'F023', building: 'F', floor: 0 }, + { number: 'F024', group: 'F024', building: 'F', floor: 0 }, + { number: 'F025A', group: 'F025', building: 'F', floor: 0 }, + { number: 'F025B', group: 'F025', building: 'F', floor: 0 }, + { number: 'F026', group: 'F026', building: 'F', floor: 0 }, + { number: 'F027', group: 'F027', building: 'F', floor: 0 }, + { number: 'F028', group: 'F028', building: 'F', floor: 0 }, + + # Building F - Floor 1 + { number: 'F101A', group: 'F101', building: 'F', floor: 1 }, + { number: 'F101B', group: 'F101', building: 'F', floor: 1 }, + { number: 'F102', group: 'F102', building: 'F', floor: 1 }, + { number: 'F103', group: 'F103', building: 'F', floor: 1 }, + { number: 'F104A', group: 'F104', building: 'F', floor: 1 }, + { number: 'F104B', group: 'F104', building: 'F', floor: 1 }, + { number: 'F105A', group: 'F105', building: 'F', floor: 1 }, + { number: 'F105B', group: 'F105', building: 'F', floor: 1 }, + { number: 'F106', group: 'F106', building: 'F', floor: 1 }, + { number: 'F107', group: 'F107', building: 'F', floor: 1 }, + { number: 'F108', group: 'F108', building: 'F', floor: 1 }, + { number: 'F109A', group: 'F109', building: 'F', floor: 1 }, + { number: 'F109B', group: 'F109', building: 'F', floor: 1 }, + { number: 'F110A', group: 'F110', building: 'F', floor: 1 }, + { number: 'F110B', group: 'F110', building: 'F', floor: 1 }, + { number: 'F111', group: 'F111', building: 'F', floor: 1 }, + { number: 'F112', group: 'F112', building: 'F', floor: 1 }, + { number: 'F113', group: 'F113', building: 'F', floor: 1 }, + { number: 'F114', group: 'F114', building: 'F', floor: 1 }, + { number: 'F115', group: 'F115', building: 'F', floor: 1 }, + { number: 'F116', group: 'F116', building: 'F', floor: 1 }, + { number: 'F117A', group: 'F117', building: 'F', floor: 1 }, + { number: 'F117B', group: 'F117', building: 'F', floor: 1 }, + { number: 'F118', group: 'F118', building: 'F', floor: 1 }, + { number: 'F119', group: 'F119', building: 'F', floor: 1 }, + { number: 'F120', group: 'F120', building: 'F', floor: 1 }, + { number: 'F121', group: 'F121', building: 'F', floor: 1 }, + { number: 'F122', group: 'F122', building: 'F', floor: 1 }, + { number: 'F123A', group: 'F123', building: 'F', floor: 1 }, + { number: 'F123B', group: 'F123', building: 'F', floor: 1 }, + { number: 'F124A', group: 'F124', building: 'F', floor: 1 }, + { number: 'F124B', group: 'F124', building: 'F', floor: 1 }, + { number: 'F125', group: 'F125', building: 'F', floor: 1 }, + { number: 'F126', group: 'F126', building: 'F', floor: 1 }, + { number: 'F127A', group: 'F127', building: 'F', floor: 1 }, + { number: 'F127B', group: 'F127', building: 'F', floor: 1 }, + { number: 'F128', group: 'F128', building: 'F', floor: 1 }, + { number: 'F129', group: 'F129', building: 'F', floor: 1 }, + { number: 'F130', group: 'F130', building: 'F', floor: 1 }, + { number: 'F131', group: 'F131', building: 'F', floor: 1 }, + { number: 'F132', group: 'F132', building: 'F', floor: 1 }, + { number: 'F133', group: 'F133', building: 'F', floor: 1 }, + { number: 'F134', group: 'F134', building: 'F', floor: 1 }, + { number: 'F135', group: 'F135', building: 'F', floor: 1 }, + { number: 'F136', group: 'F136', building: 'F', floor: 1 }, + { number: 'F137', group: 'F137', building: 'F', floor: 1 }, + + # Building F - Floor 2 + { number: 'F201A', group: 'F201', building: 'F', floor: 2 }, + { number: 'F201B', group: 'F201', building: 'F', floor: 2 }, + { number: 'F202', group: 'F202', building: 'F', floor: 2 }, + { number: 'F203', group: 'F203', building: 'F', floor: 2 }, + { number: 'F204A', group: 'F204', building: 'F', floor: 2 }, + { number: 'F204B', group: 'F204', building: 'F', floor: 2 }, + { number: 'F205A', group: 'F205', building: 'F', floor: 2 }, + { number: 'F205B', group: 'F205', building: 'F', floor: 2 }, + { number: 'F206', group: 'F206', building: 'F', floor: 2 }, + { number: 'F207', group: 'F207', building: 'F', floor: 2 }, + { number: 'F208', group: 'F208', building: 'F', floor: 2 }, + { number: 'F209A', group: 'F209', building: 'F', floor: 2 }, + { number: 'F209B', group: 'F209', building: 'F', floor: 2 }, + { number: 'F210A', group: 'F210', building: 'F', floor: 2 }, + { number: 'F210B', group: 'F210', building: 'F', floor: 2 }, + { number: 'F211', group: 'F211', building: 'F', floor: 2 }, + { number: 'F212', group: 'F212', building: 'F', floor: 2 }, + { number: 'F213', group: 'F213', building: 'F', floor: 2 }, + { number: 'F214', group: 'F214', building: 'F', floor: 2 }, + { number: 'F215', group: 'F215', building: 'F', floor: 2 }, + { number: 'F216', group: 'F216', building: 'F', floor: 2 }, + { number: 'F217A', group: 'F217', building: 'F', floor: 2 }, + { number: 'F217B', group: 'F217', building: 'F', floor: 2 }, + { number: 'F218', group: 'F218', building: 'F', floor: 2 }, + { number: 'F219', group: 'F219', building: 'F', floor: 2 }, + { number: 'F220', group: 'F220', building: 'F', floor: 2 }, + { number: 'F221', group: 'F221', building: 'F', floor: 2 }, + { number: 'F222', group: 'F222', building: 'F', floor: 2 }, + { number: 'F223A', group: 'F223', building: 'F', floor: 2 }, + { number: 'F223B', group: 'F223', building: 'F', floor: 2 }, + { number: 'F224A', group: 'F224', building: 'F', floor: 2 }, + { number: 'F224B', group: 'F224', building: 'F', floor: 2 }, + { number: 'F225', group: 'F225', building: 'F', floor: 2 }, + { number: 'F226', group: 'F226', building: 'F', floor: 2 }, + { number: 'F227A', group: 'F227', building: 'F', floor: 2 }, + { number: 'F227B', group: 'F227', building: 'F', floor: 2 }, + { number: 'F228', group: 'F228', building: 'F', floor: 2 }, + { number: 'F229', group: 'F229', building: 'F', floor: 2 }, + { number: 'F230', group: 'F230', building: 'F', floor: 2 }, + { number: 'F231', group: 'F231', building: 'F', floor: 2 }, + { number: 'F232', group: 'F232', building: 'F', floor: 2 }, + { number: 'F233', group: 'F233', building: 'F', floor: 2 }, + { number: 'F234', group: 'F234', building: 'F', floor: 2 }, + { number: 'F235', group: 'F235', building: 'F', floor: 2 }, + { number: 'F236', group: 'F236', building: 'F', floor: 2 }, + { number: 'F237', group: 'F237', building: 'F', floor: 2 }, + + # Building F - Floor 3 + { number: 'F301A', group: 'F301', building: 'F', floor: 3 }, + { number: 'F301B', group: 'F301', building: 'F', floor: 3 }, + { number: 'F302', group: 'F302', building: 'F', floor: 3 }, + { number: 'F303', group: 'F303', building: 'F', floor: 3 }, + { number: 'F304A', group: 'F304', building: 'F', floor: 3 }, + { number: 'F304B', group: 'F304', building: 'F', floor: 3 }, + { number: 'F305', group: 'F305', building: 'F', floor: 3 }, + { number: 'F306', group: 'F306', building: 'F', floor: 3 }, + { number: 'F307', group: 'F307', building: 'F', floor: 3 }, + { number: 'F308', group: 'F308', building: 'F', floor: 3 }, + { number: 'F309A', group: 'F309', building: 'F', floor: 3 }, + { number: 'F309B', group: 'F309', building: 'F', floor: 3 }, + { number: 'F310A', group: 'F310', building: 'F', floor: 3 }, + { number: 'F310B', group: 'F310', building: 'F', floor: 3 }, + { number: 'F311', group: 'F311', building: 'F', floor: 3 }, + { number: 'F312', group: 'F312', building: 'F', floor: 3 }, + { number: 'F313', group: 'F313', building: 'F', floor: 3 }, + { number: 'F314', group: 'F314', building: 'F', floor: 3 }, + { number: 'F315', group: 'F315', building: 'F', floor: 3 }, + { number: 'F316', group: 'F316', building: 'F', floor: 3 }, + { number: 'F317A', group: 'F317', building: 'F', floor: 3 }, + { number: 'F317B', group: 'F317', building: 'F', floor: 3 }, + { number: 'F318', group: 'F318', building: 'F', floor: 3 }, + { number: 'F319', group: 'F319', building: 'F', floor: 3 }, + { number: 'F320', group: 'F320', building: 'F', floor: 3 }, + { number: 'F321', group: 'F321', building: 'F', floor: 3 }, + { number: 'F322', group: 'F322', building: 'F', floor: 3 }, + { number: 'F323', group: 'F323', building: 'F', floor: 3 }, + { number: 'F324A', group: 'F324', building: 'F', floor: 3 }, + { number: 'F324B', group: 'F324', building: 'F', floor: 3 }, + { number: 'F325', group: 'F325', building: 'F', floor: 3 }, + { number: 'F326', group: 'F326', building: 'F', floor: 3 }, + { number: 'F327A', group: 'F327', building: 'F', floor: 3 }, + { number: 'F327B', group: 'F327', building: 'F', floor: 3 }, + { number: 'F328', group: 'F328', building: 'F', floor: 3 }, + { number: 'F329', group: 'F329', building: 'F', floor: 3 }, + { number: 'F330', group: 'F330', building: 'F', floor: 3 }, + { number: 'F331', group: 'F331', building: 'F', floor: 3 }, + { number: 'F332', group: 'F332', building: 'F', floor: 3 }, + { number: 'F333', group: 'F333', building: 'F', floor: 3 }, + { number: 'F334', group: 'F334', building: 'F', floor: 3 }, + { number: 'F335', group: 'F335', building: 'F', floor: 3 }, + { number: 'F336', group: 'F336', building: 'F', floor: 3 }, + { number: 'F337', group: 'F337', building: 'F', floor: 3 }, + + # Special rooms + { number: 'DF1', group: 'DF', building: 'D', floor: 0 }, + { number: 'DF2', group: 'DF', building: 'D', floor: 0 }, + { number: 'DF3', group: 'DF', building: 'D', floor: 0 }, + { number: 'DF4', group: 'DF', building: 'D', floor: 0 }, + { number: 'CLAP', group: 'ASSO', building: 'D', floor: 0 }, + { number: 'CAG', group: 'ASSO', building: 'D', floor: 0 }, + { number: 'ALUMNI', group: 'ASSO', building: 'D', floor: 0 }, + { number: 'AGR1', group: 'AGR', building: 'A', floor: 0 }, + { number: 'AGR2', group: 'AGR', building: 'A', floor: 0 }, + { number: 'FOYER', group: 'FOYER', building: 'F', floor: 0 } +] +# rubocop: enable Metrics/CollectionLiteralLength + +rooms.each do |room_attrs| + Room.find_or_create_by!(number: room_attrs[:number]) do |room| + room.group = room_attrs[:group] + room.building = room_attrs[:building] + room.floor = room_attrs[:floor] + end +end diff --git a/test/controllers/api/users_controller_test.rb b/test/controllers/api/users_controller_test.rb index af6fe05f..3687d7b3 100644 --- a/test/controllers/api/users_controller_test.rb +++ b/test/controllers/api/users_controller_test.rb @@ -18,7 +18,7 @@ def setup assert_equal @user.firstname, response_body[:firstname] assert_equal @user.lastname, response_body[:lastname] assert_equal @user.email, response_body[:email] - assert_equal @user.room, response_body[:room] + assert_equal @user.room&.number, response_body[:room] assert_equal api_user_url(@user), response_body[:url] assert_equal @user.internet_expiration, response_body[:internet_expiration] openssl_legacy_provider = OpenSSL::Provider.load('legacy') diff --git a/test/controllers/sessions_controller_local_login_test.rb b/test/controllers/sessions_controller_local_login_test.rb index 29e1109f..a889f638 100644 --- a/test/controllers/sessions_controller_local_login_test.rb +++ b/test/controllers/sessions_controller_local_login_test.rb @@ -10,7 +10,6 @@ def setup info: { first_name: 'John', last_name: 'Doe', email: 'john@doe.com', - room: 'F123', groups: 'rezoleo', username: 'john-doe' } }) end @@ -22,7 +21,7 @@ def setup end test 'should find user if already exists' do - User.create(firstname: 'John', lastname: 'Doe', email: 'john@doe.com', room: 'F123', username: 'john-doe') + User.create(firstname: 'John', lastname: 'Doe', email: 'john@doe.com', username: 'john-doe') assert_difference 'User.count', 0 do get auth_callback_developer_path diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 33e5d1b2..a353a29c 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -20,7 +20,7 @@ def setup end test 'should find user if already exists' do - User.create(firstname: 'John', lastname: 'Doe', email: 'john@doe.com', room: 'F123', username: 'john-doe', + User.create(firstname: 'John', lastname: 'Doe', email: 'john@doe.com', username: 'john-doe', oidc_id: '111111111111111111') assert_difference 'User.count', 0 do diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 74454a03..431f45d0 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -22,7 +22,7 @@ def setup get user_path @user assert_template 'users/show' assert_match @user.email, @response.body - assert_match @user.room, @response.body + assert_match @user.room.number, @response.body end test 'should get new' do @@ -37,13 +37,14 @@ def setup firstname: 'patrick', lastname: 'bar', email: 'patrick@bar.com', - room: 'E124', - username: 'patrick-bar' + username: 'patrick-bar', + room_number: 'E124' } } end user = User.find_by(email: 'patrick@bar.com') assert_redirected_to user + assert_equal 'E124', user.room.number end test 'should re-render new if user is invalid with html' do @@ -62,11 +63,12 @@ def setup firstname: 'toto', lastname: 'titi', email: 'toto@titi.tu', - room: 'B231', - username: 'toto-titi' + username: 'toto-titi', + room_number: 'B231' } } assert_redirected_to @user.reload + assert_equal 'B231', @user.room.number end test 'should re-render edit if updates are invalid with html' do diff --git a/test/controllers/users_controller_user_right_test.rb b/test/controllers/users_controller_user_right_test.rb index 828e9863..b99650c1 100644 --- a/test/controllers/users_controller_user_right_test.rb +++ b/test/controllers/users_controller_user_right_test.rb @@ -15,7 +15,6 @@ def setup get users_path assert_select 'a.user', count: 1 - assert_select 'li', text: Regexp.new(@user.room) end test 'non-admin user should not see someone else in show' do diff --git a/test/fixtures/rooms.yml b/test/fixtures/rooms.yml new file mode 100644 index 00000000..75b974bf --- /dev/null +++ b/test/fixtures/rooms.yml @@ -0,0 +1,45 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +room_a105a: + number: "A105A" + group: "A105" + building: "A" + floor: 1 + +room_a109a: + number: "A109A" + group: "A109" + building: "A" + floor: 1 + user: ironman + +room_a201: + number: "A201" + group: "A201" + building: "A" + floor: 2 + user: pepper + +room_b231: + number: "B231" + group: "B231" + building: "B" + floor: 2 + +room_e124: + number: "E124" + group: "E124" + building: "E" + floor: 1 + +room_f123: + number: "F123" + group: "F123" + building: "F" + floor: 1 + +room_df1: + number: "DF1" + group: "DF" + building: "D" + floor: 0 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 91522077..855fe6a0 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -4,7 +4,6 @@ ironman: firstname: "Tony" lastname: "Stark" email: "tony@avengers.com" - room: "A200" oidc_id: "340191509840366893" wifi_password: "12345" username: "ironman" @@ -13,7 +12,6 @@ pepper: firstname: "Pepper" lastname: "Potts" email: "pepper@potts.com" - room: "A201" oidc_id: "923403309843419676" wifi_password: "123456" username: "pepper" @@ -24,4 +22,4 @@ spiderman: email: "peterp@univ.edu" oidc_id: "326906230427557669" wifi_password: "1234567" - username: "peter.parker" + username: "peter-parker" diff --git a/test/jobs/sync_room_to_sso_job_test.rb b/test/jobs/sync_room_to_sso_job_test.rb new file mode 100644 index 00000000..f11d466c --- /dev/null +++ b/test/jobs/sync_room_to_sso_job_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SyncRoomToSsoJobTest < ActiveJob::TestCase + test 'performs sync for existing user' do + user = users(:ironman) + output = StringIO.new + logger = ActiveSupport::Logger.new(output) + old_logger = Rails.logger + + Rails.logger = logger + begin + SyncRoomToSsoJob.perform_now(user.id) + ensure + Rails.logger = old_logger + end + + assert_includes output.string, '[SSO] Dry-run: would sync room' + end + + test 'does nothing when user does not exist' do + missing_user_id = User.maximum(:id).to_i + 1 + output = StringIO.new + logger = ActiveSupport::Logger.new(output) + old_logger = Rails.logger + + Rails.logger = logger + begin + assert_nothing_raised do + SyncRoomToSsoJob.perform_now(missing_user_id) + end + ensure + Rails.logger = old_logger + end + + assert_not_includes output.string, '[SSO] Dry-run: would sync room' + end +end diff --git a/test/models/room_test.rb b/test/models/room_test.rb new file mode 100644 index 00000000..68551b50 --- /dev/null +++ b/test/models/room_test.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'test_helper' + +class RoomTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + def setup + super + @room = rooms(:room_a105a) + end + + test 'room is valid' do + assert_predicate @room, :valid? + end + + test 'number must be present' do + @room.number = nil + assert_not_predicate @room, :valid? + end + + test 'number must be unique' do + duplicate = @room.dup + assert_not_predicate duplicate, :valid? + end + + test 'number must be at most 6 characters' do + @room.number = 'A123456' + assert_not_predicate @room, :valid? + end + + test 'number must be uppercase alphanumeric' do + @room.number = 'a105a' + assert_not_predicate @room, :valid? + + @room.number = 'A1-5' + assert_not_predicate @room, :valid? + end + + test 'group must be present' do + @room.group = nil + assert_not_predicate @room, :valid? + end + + test 'group must be at most 6 characters' do + @room.group = 'A123456' + assert_not_predicate @room, :valid? + end + + test 'group must be uppercase alphanumeric' do + @room.group = 'a105' + assert_not_predicate @room, :valid? + end + + test 'building must be between A and F' do + ('A'..'F').each do |b| + @room.building = b + assert_predicate @room, :valid?, "Building #{b} should be valid" + end + + @room.building = 'G' + assert_not_predicate @room, :valid? + + @room.building = nil + assert_not_predicate @room, :valid? + end + + test 'floor must be between 0 and 3' do + (0..3).each do |f| + @room.floor = f + assert_predicate @room, :valid?, "Floor #{f} should be valid" + end + + @room.floor = 4 + assert_not_predicate @room, :valid? + + @room.floor = -1 + assert_not_predicate @room, :valid? + + @room.floor = nil + assert_not_predicate @room, :valid? + end + + test 'belongs_to user association' do + room = rooms(:room_a109a) + assert_equal users(:ironman), room.user + end + + test 'user_id must be unique when not nil' do + room = rooms(:room_b231) + room.user = users(:ironman) + assert_not_predicate room, :valid? + assert_includes room.errors[:user_id], 'has already been taken' + end + + test 'multiple rooms can have nil user' do + room1 = rooms(:room_a105a) + room2 = rooms(:room_b231) + assert_nil room1.user + assert_nil room2.user + assert_predicate room1, :valid? + assert_predicate room2, :valid? + end + + test 'room without user can be destroyed' do + room = rooms(:room_a105a) + assert room.destroy + end + + test 'room with user can be destroyed and nullifies user association' do + room = rooms(:room_a109a) + user = room.user + assert room.destroy + user.reload + assert_nil user.room + end + + test 'available_for returns rooms not occupied by other users' do + user = users(:ironman) + available = Room.available_for(user) + + # Should include the user's own room + assert_includes available.map(&:number), user.room.number + + # Should not include other users' rooms + assert_not_includes available.map(&:number), users(:pepper).room.number + + # Should include unoccupied rooms + assert_includes available.map(&:number), rooms(:room_a105a).number + end + + test 'changing user_id enqueues room sync job for new user' do + room = rooms(:room_a105a) + user = users(:spiderman) + + assert_enqueued_with(job: SyncRoomToSsoJob, args: [user.id]) do + room.update!(user: user) + end + end + + test 'changing user_id enqueues room sync job for previous user' do + room = rooms(:room_a109a) + room.user + new_user = users(:spiderman) + assert_enqueued_jobs(2, only: SyncRoomToSsoJob) do + room.update!(user: new_user) + end + end + + test 'removing user_id enqueues room sync job for previous user' do + room = rooms(:room_a109a) + old_user = room.user + + assert_enqueued_with(job: SyncRoomToSsoJob, args: [old_user.id]) do + room.update!(user: nil) + end + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 6a6c4fe0..6ad81a13 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -3,6 +3,8 @@ require 'test_helper' class UserTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + def setup super @user = users(:ironman) @@ -68,49 +70,13 @@ def setup end test 'room can be nil' do - @user.room = nil - assert_predicate @user, :valid? + user = users(:spiderman) + assert_nil user.room + assert_predicate user, :valid? end - test 'room should be unique when not nil' do - duplicate_user = @user.dup - duplicate_user.email = 'different@email.com' - duplicate_user.username = 'different-username' - @user.save - duplicate_user.room.downcase! - assert_not_predicate duplicate_user, :valid? - end - - test 'multiple users can have nil room' do - @user.room = nil - another_user = @user.dup - another_user.email = 'different@email.com' - another_user.username = 'different-username' - another_user.room = nil - @user.save - assert_predicate another_user, :valid? - end - - test 'room should be formatted on save' do - @user.room = 'a108B' - @user.save - assert_equal 'A108b', @user.room - # TODO: Add tests to check that we strip rooms, and that we keep uppercase on 'DF1' - end - - test 'room must be of a valid format' do - valid_rooms = ['A205', 'B134a', 'C001b', 'F313', 'D111b', 'E231a', 'DF1', 'DF2', 'DF3', 'DF4'] - invalid_rooms = ['A2005', 'C404', 'D111c', 'B1', 'E22', 'G207'] - - valid_rooms.each do |valid_room| - @user.room = valid_room - assert_predicate @user, :valid?, "#{valid_room} should be valid" - end - - invalid_rooms.each do |invalid_room| - @user.room = invalid_room - assert_not_predicate @user, :valid?, "#{invalid_room} should be invalid" - end + test 'user can have a room association' do + assert_equal rooms(:room_a109a), @user.room end test "username can't be empty" do @@ -180,7 +146,6 @@ def setup test 'should update existing user from auth hash' do @user.update(oidc_id: '111111111111111111') - original_room = @user.room @user.save User.upsert_from_auth_hash(@auth_hash) @@ -189,7 +154,6 @@ def setup assert_equal 'John', @user.firstname assert_equal 'Doe', @user.lastname assert_equal 'john@doe.com', @user.email - assert_equal original_room, @user.room assert_equal '111111111111111111', @user.oidc_id end diff --git a/test/services/sso_metadata_service_test.rb b/test/services/sso_metadata_service_test.rb new file mode 100644 index 00000000..fdcdf85d --- /dev/null +++ b/test/services/sso_metadata_service_test.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SsoMetadataServiceTest < ActiveSupport::TestCase + def setup + super + + @user = users(:ironman) + Rails.application.credentials.sso_lea5_pat = 'test-pat-token' + end + + test 'sync_room skips user without oidc_id' do + @user.oidc_id = nil + assert_nothing_raised do + SsoMetadataService.new.sync_room(@user) + end + end + + test 'sync_room does not call SSO in non-production' do + assert_not_equal 'production', Rails.env + + SsoMetadataService.new.sync_room(@user) + end + + test 'sync_room pushes metadata in production' do + room_number = @user.room.number + stub = WebMock.stub_request(:post, "https://sso.rezoleo.fr/v2/users/#{@user.oidc_id}/metadata") + .with( + headers: { 'Authorization' => 'Bearer test-pat-token', 'Content-Type' => 'application/json' }, + body: { + metadata: [{ key: 'room', value: Base64.strict_encode64(room_number) }] + }.to_json + ) + .to_return(status: 200, body: '{"setDate":"2026-01-01T00:00:00Z"}') + + ProductionSsoService.new.sync_room(@user) + assert_requested(stub) + end + + test 'sync_room sends DELETE when user has no room' do + user = users(:spiderman) # User without a room + + stub = WebMock.stub_request(:delete, "https://sso.rezoleo.fr/v2/users/#{user.oidc_id}/metadata") + .with(query: { 'keys' => 'room' }) + .to_return(status: 200, body: '{"deletionDate":"2026-01-01T00:00:00Z"}') + + ProductionSsoService.new.sync_room(user) + assert_requested(stub) + end + + test 'sync_room does not raise on POST HTTP failure' do + WebMock.stub_request(:post, "https://sso.rezoleo.fr/v2/users/#{@user.oidc_id}/metadata") + .to_return(status: 500, body: 'Internal Server Error') + + assert_nothing_raised do + ProductionSsoService.new.sync_room(@user) + end + end + + test 'sync_room raises on POST network exception' do + WebMock.stub_request(:post, "https://sso.rezoleo.fr/v2/users/#{@user.oidc_id}/metadata") + .to_raise(Errno::ECONNREFUSED) + + assert_raises(Errno::ECONNREFUSED) do + ProductionSsoService.new.sync_room(@user) + end + end + + test 'sync_room does not raise on DELETE HTTP failure' do + user = users(:spiderman) # User without a room + + WebMock.stub_request(:delete, "https://sso.rezoleo.fr/v2/users/#{user.oidc_id}/metadata") + .with(query: { 'keys' => 'room' }) + .to_return(status: 500, body: 'Internal Server Error') + + assert_nothing_raised do + ProductionSsoService.new.sync_room(user) + end + end + + test 'sync_room raises on DELETE network exception' do + user = users(:spiderman) # User without a room + + WebMock.stub_request(:delete, "https://sso.rezoleo.fr/v2/users/#{user.oidc_id}/metadata") + .with(query: { 'keys' => 'room' }) + .to_raise(Errno::ECONNREFUSED) + + assert_raises(Errno::ECONNREFUSED) do + ProductionSsoService.new.sync_room(user) + end + end + + test 'sync_room raises on timeout' do + user = users(:spiderman) # User without a room + + WebMock.stub_request(:delete, "https://sso.rezoleo.fr/v2/users/#{user.oidc_id}/metadata") + .with(query: { 'keys' => 'room' }) + .to_raise(Net::ReadTimeout.new) + + assert_raises(Net::ReadTimeout) do + ProductionSsoService.new.sync_room(user) + end + end +end + +# Test subclass that overrides production? to always return true +class ProductionSsoService < SsoMetadataService + private + + def production? + true + end +end diff --git a/test/tasks/sync_accounts_test.rb b/test/tasks/sync_accounts_test.rb index 34c376d5..47d76e7d 100644 --- a/test/tasks/sync_accounts_test.rb +++ b/test/tasks/sync_accounts_test.rb @@ -18,14 +18,12 @@ def setup ZitadelStub.stub_list_users tony = User.find_by(email: 'tony@avengers.com') - original_room = tony.room assert_difference 'User.count', -1 do Rake::Task['lea5:sync_accounts'].invoke end tony.reload - assert_equal original_room, tony.room end end