diff --git a/app/controllers/api/v2/specific_tube_rack_creations_controller.rb b/app/controllers/api/v2/specific_tube_rack_creations_controller.rb new file mode 100644 index 0000000000..24a69ab5ae --- /dev/null +++ b/app/controllers/api/v2/specific_tube_rack_creations_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Api + module V2 + # Provides a JSON API controller for Specific Tube Rack Creations + # See: http://jsonapi-resources.com/ for JSONAPI::Resource documentation + class SpecificTubeRackCreationsController < JSONAPI::ResourceController + # By default JSONAPI::ResourceController provides most the standard + # behaviour, and in many cases this file may be left empty. + end + end +end diff --git a/app/controllers/api/v2/tube_rack_purposes_controller.rb b/app/controllers/api/v2/tube_rack_purposes_controller.rb index 6aa102b84d..6fd34a0c95 100644 --- a/app/controllers/api/v2/tube_rack_purposes_controller.rb +++ b/app/controllers/api/v2/tube_rack_purposes_controller.rb @@ -2,7 +2,7 @@ module Api module V2 - # Provides a JSON API controller for tube purposes. + # Provides a JSON API controller for tube rack purposes. # See: http://jsonapi-resources.com/ for JSONAPI::Resource documentation class TubeRackPurposesController < JSONAPI::ResourceController # By default JSONAPI::ResourceController provides most of the standard diff --git a/app/models/asset_link.rb b/app/models/asset_link.rb index 46c39328b6..a9b0ca0bc2 100644 --- a/app/models/asset_link.rb +++ b/app/models/asset_link.rb @@ -35,19 +35,24 @@ def destroy! end module Associations + # rubocop:disable Metrics/MethodLength def self.included(base) base.class_eval do extend ClassMethods has_dag_links link_class_name: 'AssetLink' - has_many :child_plates, through: :links_as_parent, source: :descendant, class_name: 'Plate' has_many :child_tubes, through: :links_as_parent, source: :descendant, class_name: 'Tube' + has_many :child_plates, through: :links_as_parent, source: :descendant, class_name: 'Plate' + has_many :child_tube_racks, through: :links_as_parent, source: :descendant, class_name: 'TubeRack' has_many :parent_tubes, through: :links_as_child, source: :ancestor, class_name: 'Tube' has_many :parent_plates, through: :links_as_child, source: :ancestor, class_name: 'Plate' + has_many :parent_tube_racks, through: :links_as_child, source: :ancestor, class_name: 'TubeRack' end base.extend(ClassMethods) end + # rubocop:enable Metrics/MethodLength + module ClassMethods def has_one_as_child(name, scope) # rubocop:todo Metrics/MethodLength plural_name = name.to_s.pluralize.to_sym diff --git a/app/models/specific_tube_rack_creation.rb b/app/models/specific_tube_rack_creation.rb new file mode 100644 index 0000000000..d512def4bb --- /dev/null +++ b/app/models/specific_tube_rack_creation.rb @@ -0,0 +1,435 @@ +# frozen_string_literal: true +# Allows for the creation of multiple tube racks and their tubes. +# rubocop:disable Metrics/ClassLength +class SpecificTubeRackCreation < AssetCreation + # Allows a many to many relationship between SpecificTubeRackCreations and child Tube racks. + class ChildTubeRack < ApplicationRecord + self.table_name = ('specific_tube_rack_creation_children') + belongs_to :specific_tube_rack_creation + belongs_to :tube_rack + end + + # Allows a many to many relationship between SpecificTubeRackCreations and Purposes. + class ChildPurpose < ApplicationRecord + self.table_name = 'specific_tube_rack_creation_purposes' + belongs_to :specific_tube_rack_creation + belongs_to :tube_rack_purpose, class_name: 'Purpose' + end + + has_many :creation_child_purposes, class_name: 'SpecificTubeRackCreation::ChildPurpose', dependent: :destroy + has_many :child_purposes, through: :creation_child_purposes, source: :tube_rack_purpose + + has_many :child_tube_racks, class_name: 'SpecificTubeRackCreation::ChildTubeRack', dependent: :destroy + has_many :children, through: :child_tube_racks, source: :tube_rack + + validates :tube_rack_attributes, presence: true + + has_many :parent_associations, + foreign_key: 'asset_creation_id', + class_name: 'AssetCreation::ParentAssociation', + inverse_of: 'asset_creation', + dependent: :destroy + + # NB. assumption that parent is a plate + belongs_to :parent, class_name: 'Plate' + has_many :parents, through: :parent_associations, class_name: 'Plate' + + # @param value [Array] Hashes defining the attributes to apply to each tube rack and + # the tubes within that are being created. + # This is used to set custom attributes on the tube racks, such as name. As well as to create + # the tubes within the tube rack and link them together. + # @example [ + # { + # :tube_rack_name=>"Tube Rack", + # :tube_rack_barcode=>"TR00000001", + # :tube_rack_purpose_uuid=>"0ab4c9cc-4dad-11ef-8ca3-82c61098d1a1", + # :racked_tubes=>[ + # { + # :tube_barcode=>"SQ45303801", + # :tube_name=>"SEQ:NT749R:A1", + # :tube_purpose_uuid=>"0ab4c9cc-4dad-11ef-8ca3-82c61098d1a1", + # :tube_position=>"A1", + # :parent_uuids=>["bd49e7f8-80a1-11ef-bab6-82c61098d1a0"] + # }, + # etc... more tubes + # ] + # }, + # etc... more tube racks + attr_writer :tube_rack_attributes + + DEFAULT_TARGET_TYPE = 'TubeRack' + + # singular 'parent' getter to stay backwards compatible + def parent + parents.first + end + + # singular 'parent' setter to stay backwards compatible + def parent=(parent) + self.parents = [parent] + end + + # If no tube rack attributes are specified, fall back to an empty array + def tube_rack_attributes + @tube_rack_attributes || Array.new(child_purposes.length, {}) + end + + # See asset_creation, flag to indicate that multiple child purposes are expected and skip validation + def multiple_purposes + true + end + + private + + def target_for_ownership + children + end + + # Connect parent labware to child tube racks + # Method overridden from AssetCreation + def connect_parent_and_children + parents.each { |parent| children.each { |child| AssetLink.create_edge!(parent, child) } } + end + + # Creates child tube racks based on the provided tube rack attributes. + # + # This method iterates over the @tube_rack_attributes array and creates a child tube rack + # for each set of attributes. The created child tube racks are assigned to the `children` + # attribute of the current object. + # + # @raise [StandardError] if any of the child tube rack creation processes fail. + # + # @return [Array] An array of the created child tube racks. + def create_children! + # initialise tube purposes hash to avoid multiple queries + @tube_purposes = {} + + self.children = + @tube_rack_attributes.each_with_index.map { |rack_attributes, _index| create_child_tube_rack(rack_attributes) } + end + + # Creates a child tube rack based on the provided rack attributes. + # + # This method performs the following steps: + # 1. Finds the child purpose using the provided tube rack purpose UUID. + # 2. Adds the found child purpose to the `child_purposes` attribute. + # 3. Creates a new tube rack with the provided name under the found child purpose. + # 4. Handles the tube rack barcode, either creating a new barcode or redirecting an existing one. + # 5. Adds metadata to the new tube rack using the provided metadata key and barcode. + # 6. Creates the tubes for the new tube rack using the provided tube attributes. + # + # @param [Hash] rack_attributes The attributes for the tube rack, including: + # - :tube_rack_purpose_uuid [String] The UUID of the tube rack purpose. + # - :tube_rack_name [String] The name of the tube rack. + # - :tube_rack_barcode [String] The barcode of the tube rack. + # - :racked_tubes [Array] An array of hashes defining the tubes to be created within the tube rack. + # + # @raise [StandardError] if any of the tube rack creation processes fail. + # + # @return [TubeRack] The created tube rack. + def create_child_tube_rack(rack_attributes) + child_purpose = find_tube_rack_purpose(rack_attributes[:tube_rack_purpose_uuid]) + child_purposes << child_purpose + + new_tube_rack = child_purpose.create!(name: rack_attributes[:tube_rack_name]) + handle_tube_rack_barcode(rack_attributes[:tube_rack_barcode], new_tube_rack) + add_tube_rack_metadata(rack_attributes[:tube_rack_barcode], new_tube_rack) + + create_racked_tubes(rack_attributes[:racked_tubes], new_tube_rack) + + new_tube_rack + end + + # Finds the tube rack purpose using the provided UUID. + # + # This method queries the TubeRack::Purpose model to find the first record + # that matches the given UUID. If a matching record is found, it is returned. + # If no matching record is found, an error is raised. + # + # @param [String] uuid The UUID of the tube rack purpose to find. + # + # @raise [StandardError] if no matching TubeRack::Purpose record is found. + # + # @return [TubeRack::Purpose] The found TubeRack::Purpose object. + def find_tube_rack_purpose(uuid) + tr_purpose = TubeRack::Purpose.with_uuid(uuid).first + return tr_purpose if tr_purpose + + error_message = "The tube rack purpose with UUID '#{uuid}' was not found." + raise StandardError, error_message + end + + # Handles the barcode assignment for a new tube rack. + # + # This method checks if a barcode already exists for the given tube rack barcode. + # If the barcode does not exist, it creates a new barcode for the tube rack. + # If the barcode already exists, it redirects the existing barcode to the new tube rack instance. + # This is done to allow the re-use of the physical tube rack, which has an etched barcode. + # + # @param [String] tube_rack_barcode The barcode of the tube rack. + # @param [TubeRack] new_tube_rack The new tube rack object to which the barcode will be assigned. + # + # @raise [StandardError] if the barcode cannot be created or redirected. + # + # @return [void] + def handle_tube_rack_barcode(tube_rack_barcode, new_tube_rack) + existing_barcode_record = Barcode.includes(:asset).find_by(barcode: tube_rack_barcode) + + if existing_barcode_record.nil? + create_new_barcode(tube_rack_barcode, new_tube_rack) + else + redirect_existing_barcode(existing_barcode_record, new_tube_rack, tube_rack_barcode) + end + end + + # Creates a new barcode for the given tube rack. + # + # This method checks if the provided tube rack barcode matches a recognized format. + # If the barcode format is valid, it creates a new Barcode record associated with the new tube rack. + # If the barcode format is not recognized, it raises an error. + # + # @param [String] tube_rack_barcode The barcode of the tube rack. + # @param [TubeRack] new_tube_rack The new tube rack object to which the barcode will be assigned. + # + # @raise [StandardError] if the barcode format is not recognized. + # + # @return [Barcode] The created Barcode object. + def create_new_barcode(tube_rack_barcode, new_tube_rack) + barcode_format = Barcode.matching_barcode_format(tube_rack_barcode) + if barcode_format.nil? + error_message = "The tube rack barcode '#{tube_rack_barcode}' is not a recognised format." + raise StandardError, error_message + end + Barcode.create!(labware: new_tube_rack, barcode: tube_rack_barcode, format: barcode_format) + end + + # Redirects an existing barcode to a new tube rack. + # + # This method checks if the existing barcode is associated with a TubeRack. + # If it is, the barcode is reassigned to the new tube rack. + # If it is not, an error is raised indicating that the barcode is already in use by another type of labware. + # + # @param [Barcode] existing_barcode_record The existing Barcode record to be redirected. + # @param [TubeRack] new_tube_rack The new tube rack object to which the barcode will be reassigned. + # @param [String] tube_rack_barcode The barcode of the tube rack. + # + # @raise [StandardError] if the barcode is already in use by another type of labware. + # + # @return [void] + def redirect_existing_barcode(existing_barcode_record, new_tube_rack, tube_rack_barcode) + existing_labware = existing_barcode_record.labware + + if existing_labware.is_a?(TubeRack) + existing_barcode_record.labware = new_tube_rack + else + error_message = + "The tube rack barcode '#{tube_rack_barcode}' is already in use by " \ + 'another type of labware, cannot create tube rack.' + raise StandardError, error_message + end + end + + # Adds metadata to a tube rack. + # + # This method creates a new PolyMetadatum record with the provided metadata key and tube rack barcode. + # The metadatable_type and metadatable_id are set to the class and ID of the tube rack, respectively. + # If the metadata record fails to save, an error is raised. + # + # @param [String] tube_rack_barcode The barcode of the tube rack to be used as the metadata value. + # @param [TubeRack] tube_rack The tube rack object to which the metadata will be added. + # + # @raise [StandardError] if the metadata record fails to save. + # + # @return [void] + def add_tube_rack_metadata(tube_rack_barcode, tube_rack) + metadata_key = Rails.application.config.tube_racks_config[:tube_rack_barcode_key] + pm = + PolyMetadatum.new( + key: metadata_key, + value: tube_rack_barcode, + metadatable_type: tube_rack.class.name, + metadatable_id: tube_rack.id + ) + return if pm.save + + raise StandardError, "New metadata for tube rack (key: #{metadata_key}, value: #{tube_rack_barcode}) did not save" + end + + # Creates racked tubes based on the provided attributes and associates them with a new tube rack. + # + # This method iterates over the array of racked tube attributes provided in the rack_attributes + # hash. + # For each set of tube attributes, it calls the create_racked_tube method to create and associate + # the tube with the new tube rack. + # + # @param [Array] racked_tubes An array of hashes, each containing attributes for a racked tube. + # @param [TubeRack] new_tube_rack The new tube rack object to which the tubes will be associated. + # + # @raise [StandardError] if any of the tube creation processes fail. + # + # @return [void] + def create_racked_tubes(racked_tubes, new_tube_rack) + # iterate through the rack attributes to create each tube + racked_tubes.each { |tube_attributes| create_racked_tube(tube_attributes, new_tube_rack) } + end + + def create_racked_tube(tube_attributes, new_tube_rack) + tube = create_tube(tube_attributes) + link_tube_to_rack(tube, new_tube_rack, tube_attributes[:tube_position]) + end + + # Ensures that the provided tube barcode is unique. + # + # This method checks if a tube barcode already exists in the database. + # If the barcode is found, it raises a StandardError indicating that the barcode is already in use. + # If the barcode is not found, the method simply returns, allowing the process to continue. + # + # @param [String] tube_barcode The barcode of the tube to be checked for uniqueness. + # + # @raise [StandardError] if the tube barcode is already in use. + # + # @return [void] + def ensure_unique_tube_barcode(tube_barcode) + existing_tube_barcode_record = Barcode.includes(:asset).find_by(asset_id: tube_barcode) + return if existing_tube_barcode_record.nil? + + error_message = "The tube barcode '#{tube_barcode}' is already in use, cannot continue." + raise StandardError, error_message + end + + # Checks the format of the provided tube barcode. + # + # This method verifies that the provided tube barcode matches a recognized format. + # It first checks if the barcode format is recognized. If not, it raises a StandardError. + # Then, it checks if the barcode format is of the expected 'fluidx_barcode' type. + # If the barcode format is not 'fluidx_barcode', it raises a StandardError. + # + # @param [String] tube_barcode The barcode of the tube to be checked. + # + # @raise [StandardError] if the barcode format is not recognized or if it is not of the + # expected 'fluidx_barcode' type. + # + # @return [void] + def check_tube_barcode_format(tube_barcode) + barcode_format = Barcode.matching_barcode_format(tube_barcode) + + # barcode format should be recognised + if barcode_format.nil? + error_message = "The tube barcode '#{tube_barcode}' is not a recognised format." + raise StandardError, error_message + end + + # expecting fluidx format + return if barcode_format == :fluidx_barcode + + error_message = "The tube barcode '#{tube_barcode}' is not of the expected fluidx type." + raise StandardError, error_message + end + + # Creates a new tube based on the provided attributes. + # + # This method performs the following steps: + # 1. Extracts the tube barcode from the provided tube attributes. + # 2. Checks if the tube barcode format is valid. + # 3. Ensures that the tube barcode is unique by checking for existing records. + # 4. Checks if the tube purpose UUID is valid and fetches the corresponding tube purpose. + # 5. Creates the tube via the tube purpose with the provided name. + # 6. Sets the foreign barcode after initial creation to ensure it is set as the 'primary' barcode. + # 7. Reloads the tube to ensure all attributes are up-to-date. + # + # @param [Hash] tube_attributes The attributes for the tube, including: + # - :tube_barcode [String] The barcode of the tube. + # - :tube_name [String] The name of the tube. + # - :tube_purpose_uuid [String] The UUID of the tube purpose. + # + # @raise [StandardError] if any of the validation or creation processes fail. + # + # @return [Tube] The created Tube object. + def create_tube(tube_attributes) + tube_barcode = tube_attributes[:tube_barcode] + + # check barcode format is valid + check_tube_barcode_format(tube_barcode) + + # check barcode is not in use + ensure_unique_tube_barcode(tube_barcode) + + # check tube purpose uuid is valid and fetch tube purpose + tube_purpose = find_tube_purpose(tube_attributes[:tube_purpose_uuid]) + + # create the tube via the tube purpose + tube = tube_purpose.create!(name: tube_attributes[:tube_name]) + + # set the foreign barcode is after initial creation to ensure the barcode is set as the 'primary' barcode + tube.foreign_barcode = tube_barcode + tube.reload + end + + # Links a tube to a tube rack at a specified position. + # + # This method creates a new RackedTube object that associates the provided tube + # with the specified tube rack at the given position. The RackedTube object is then saved to the database. + # + # @param [Tube] tube The tube object to be linked to the tube rack. + # @param [TubeRack] new_tube_rack The tube rack object to which the tube will be linked. + # @param [String] tube_position The position of the tube in the tube rack. + # + # @raise [ActiveRecord::RecordInvalid] if the RackedTube object fails to save. + # + # @return [void] + def link_tube_to_rack(tube, new_tube_rack, tube_position) + racked_tube = RackedTube.new(tube: tube, tube_rack: new_tube_rack, coordinate: tube_position) + return if racked_tube.save! + + error_message = + "The tube '#{tube.name}' could not be linked to the tube rack '#{new_tube_rack.name}' " \ + "at position '#{tube_position}'." + raise StandardError, error_message + end + + # Finds the tube purpose based on the provided UUID, using a cached hash to avoid multiple queries. + # + # This method checks a hash of saved purposes to see if the tube purpose with the specified UUID + # has already been fetched. If it has, the cached purpose is returned. If it has not, the method + # fetches the tube purpose from the database, caches it in the hash, and then returns it. + # + # @param [String] uuid The UUID of the tube purpose to be found. + # + # @raise [StandardError] if the tube purpose with the specified UUID is not found. + # + # @return [Tube::Purpose] The found Tube::Purpose object. + def find_tube_purpose(uuid) + # check a hash of saved purposes to avoid multiple queries + @tube_purposes[uuid] ||= fetch_tube_purpose(uuid) + end + + # Fetches the tube purpose based on the provided UUID. + # + # This method searches for a Tube::Purpose record with the specified UUID. + # If a matching record is found, it is cached in the @tube_purposes hash to avoid multiple queries + # and then returned. If no matching record is found, a StandardError is raised with an appropriate error message. + # + # @param [String] uuid The UUID of the tube purpose to be found. + # + # @raise [StandardError] if the tube purpose with the specified UUID is not found. + # + # @return [Purpose] The found Purpose object. + def fetch_tube_purpose(uuid) + tube_purpose = Tube::Purpose.with_uuid(uuid).first + if tube_purpose + # save the found purpose to avoid multiple queries + @tube_purposes[uuid] = tube_purpose + return tube_purpose + end + + error_message = "The tube purpose with UUID '#{uuid}' was not found." + raise StandardError, error_message + end + + # Inherited from AssetCreation + def record_creation_of_children + # Not generating creation events for this labware + end +end + +# rubocop:enable Metrics/ClassLength diff --git a/app/models/tube_rack.rb b/app/models/tube_rack.rb index efac025edd..8139ed9e20 100644 --- a/app/models/tube_rack.rb +++ b/app/models/tube_rack.rb @@ -4,12 +4,15 @@ # Tubes are linked via the RackedTubes association class TubeRack < Labware include Barcode::Barcodeable + include Asset::Ownership::Unowned self.sample_partial = 'assets/samples_partials/tube_rack_samples' has_many :racked_tubes, dependent: :destroy, inverse_of: :tube_rack has_many :tubes, through: :racked_tubes has_many :contained_samples, through: :tubes, source: :samples + # TODO: change to purpose_id + belongs_to :purpose, class_name: 'TubeRack::Purpose', foreign_key: :plate_purpose_id, inverse_of: :tube_racks # The receptacles within the tubes. # While it may be tempting to just name this association :receptacles it interferes diff --git a/app/models/tube_rack/purpose.rb b/app/models/tube_rack/purpose.rb index 01ecbc3228..2be1fe919c 100644 --- a/app/models/tube_rack/purpose.rb +++ b/app/models/tube_rack/purpose.rb @@ -3,11 +3,19 @@ # The purpose of a tube rack is to hold tubes. # Created to hold the size of the tube rack for use when generating manifests. class TubeRack::Purpose < Purpose - self.default_prefix = 'TR' - has_many :sample_manifests, inverse_of: :tube_rack_purpose, dependent: :restrict_with_exception + # TODO: change to purpose_id + has_many :tube_racks, foreign_key: :plate_purpose_id, inverse_of: :purpose, dependent: :restrict_with_exception + def self.standard_tube_rack TubeRack::Purpose.find_by(name: 'TR Stock 96') end + + def create!(*args, &block) + options = args.extract_options! + options[:purpose] = self + options[:size] = size + target_class.create!(*args, options, &block).tap { |tr| tube_racks << tr } + end end diff --git a/app/resources/api/v2/pooled_plate_creation_resource.rb b/app/resources/api/v2/pooled_plate_creation_resource.rb index 3db50371c7..79f93ce1dd 100644 --- a/app/resources/api/v2/pooled_plate_creation_resource.rb +++ b/app/resources/api/v2/pooled_plate_creation_resource.rb @@ -55,7 +55,7 @@ def user_uuid=(value) end # @!attribute [r] uuid - # @return [String] The UUID of the state change. + # @return [String] The UUID of the plate creation instance. attribute :uuid, readonly: true ### diff --git a/app/resources/api/v2/request_resource.rb b/app/resources/api/v2/request_resource.rb index bf12598965..2ba0fa8223 100644 --- a/app/resources/api/v2/request_resource.rb +++ b/app/resources/api/v2/request_resource.rb @@ -27,7 +27,10 @@ class RequestResource < BaseResource # Associations: has_one :submission, always_include_linkage_data: true has_one :order, always_include_linkage_data: true - has_one :request_type, always_include_linkage_data: true + # NB. adding always_include_linkage_data: true caused a problem in the JSON Api client in Limber in that it has + # data for the request_type but doesn't understand it needs to query the request_type endpoint to get the full + # information. This results in a nil being returned for the request_type. + has_one :request_type has_one :primer_panel has_one :pre_capture_pool has_many :poly_metadata, as: :metadatable, class_name: 'PolyMetadatum' diff --git a/app/resources/api/v2/request_type_resource.rb b/app/resources/api/v2/request_type_resource.rb index 8b70c4569d..8076779623 100644 --- a/app/resources/api/v2/request_type_resource.rb +++ b/app/resources/api/v2/request_type_resource.rb @@ -24,6 +24,7 @@ class RequestTypeResource < BaseResource default_includes :uuid_object # Associations: + has_many :requests # Attributes attribute :uuid, readonly: true diff --git a/app/resources/api/v2/specific_tube_creation_resource.rb b/app/resources/api/v2/specific_tube_creation_resource.rb index d3cfae2529..7ab2a8345d 100644 --- a/app/resources/api/v2/specific_tube_creation_resource.rb +++ b/app/resources/api/v2/specific_tube_creation_resource.rb @@ -70,7 +70,7 @@ def user_uuid=(value) end # @!attribute [r] uuid - # @return [String] The UUID of the state change. + # @return [String] The UUID of the AssetCreation instance. attribute :uuid, readonly: true ### diff --git a/app/resources/api/v2/specific_tube_rack_creation_resource.rb b/app/resources/api/v2/specific_tube_rack_creation_resource.rb new file mode 100644 index 0000000000..cc27a20ec2 --- /dev/null +++ b/app/resources/api/v2/specific_tube_rack_creation_resource.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module Api + module V2 + # This resource represents the api v2 resource for the specific tube rack creations endpoint. + # This endpoint is used to create tube rack instances and the racked tubes within them. + # + # @note This resource cannot be modified after creation: its endpoint will not accept `PATCH` requests. + # @note Access this resource via the `/api/v2/specific_tube_rack_creations/` endpoint. + # + # Provides a JSON:API representation of {SpecificTubeRackCreation}. + # + # For more information about JSON:API see the [JSON:API Specifications](https://jsonapi.org/format/) + # or look at the [JSONAPI::Resources](http://jsonapi-resources.com/) package for Sequencescape's implementation + # of the JSON:API standard. + class SpecificTubeRackCreationResource < BaseResource + ### + # Attributes + ### + + # @!attribute [w] parent_uuids + # This is declared for convenience where the parent is not available to set as a relationship. + # Setting this attribute alongside the `parents` relationship will prefer the relationship value. + # @deprecated Use the `parents` relationship instead. + # @param value [Array] The UUIDs of labware that will be the parents for all tube racks + # and tubes created. + # @return [Void] + # @see #parents + attribute :parent_uuids + + def parent_uuids=(value) + @model.parents = value.map { |uuid| Labware.with_uuid(uuid).first } + end + + # @!attribute [w] tube_rack_attributes + # @param value [Array] Hashes defining the attributes to apply to each tube rack and + # the tubes within that are being created. + # This is used to set custom attributes on the tube racks, such as name. As well as to create + # the tubes within the tube rack and link them together. + # @example [ + # { + # :tube_rack_name=>"Tube Rack", + # :tube_rack_barcode=>"TR00000001", + # :tube_rack_purpose_uuid=>"0ab4c9cc-4dad-11ef-8ca3-82c61098d1a1", + # :racked_tubes=>[ + # { + # :tube_barcode=>"SQ45303801", + # :tube_name=>"SEQ:NT749R:A1", + # :tube_purpose_uuid=>"0ab4c9cc-4dad-11ef-8ca3-82c61098d1a1", + # :tube_position=>"A1", + # :parent_uuids=>["bd49e7f8-80a1-11ef-bab6-82c61098d1a0"] + # }, + # etc... more tubes + # ] + # }, + # etc... more tube racks + # + # @return [Void] + attribute :tube_rack_attributes + + def tube_rack_attributes=(value) + return if value.nil? + + # Convert ActionController::Parameters into hashes. + @model.tube_rack_attributes = value.map(&:to_unsafe_h) + end + + # @!attribute [w] user_uuid + # This is declared for convenience where the user is not available to set as a relationship. + # Setting this attribute alongside the `user` relationship will prefer the relationship value. + # @deprecated Use the `user` relationship instead. + # @param value [String] The UUID of the user who initiated the creation of tubes. + # @return [Void] + # @see #user + attribute :user_uuid + + def user_uuid=(value) + @model.user = User.with_uuid(value).first + end + + # @!attribute [r] uuid + # @return [String] The UUID of the AssetCreation instance. + attribute :uuid, readonly: true + + ### + # Relationships + ### + + # @!attribute [r] children + # @return [Array] An array of tube racks that were created. + has_many :children, class_name: 'TubeRack' + + # @!attribute [rw] parents + # Setting this relationship alongside the `parent_uuids` attribute will override the attribute value. + # @return [Array] An array of the parents of the tubes being created. + # @note This relationship is required. + has_many :parents, class_name: 'Labware' + + # @!attribute [rw] user + # Setting this relationship alongside the `user_uuid` attribute will override the attribute value. + # @return [UserResource] The user who initiated the creation of tubes. + # @note This relationship is required. + has_one :user + + def self.creatable_fields(context) + # UUID is set by the system. + super - %i[uuid] + end + + def fetchable_fields + # The tube_rack_attributes attribute is only available during resource creation. + # UUIDs for relationships are not fetchable. They should be accessed via the relationship itself. + super - %i[parent_uuids tube_rack_attributes user_uuid] + end + end + end +end diff --git a/app/resources/api/v2/tube_rack_purpose_resource.rb b/app/resources/api/v2/tube_rack_purpose_resource.rb index 761b9610a0..760e7271e3 100644 --- a/app/resources/api/v2/tube_rack_purpose_resource.rb +++ b/app/resources/api/v2/tube_rack_purpose_resource.rb @@ -54,6 +54,10 @@ def self.creatable_fields(_context) def self.updatable_fields(_context) super - %i[uuid] # Do not allow creating with any readonly fields end + + filter :type, default: 'TubeRack::Purpose' + + filter :name end end end diff --git a/app/resources/api/v2/tube_rack_resource.rb b/app/resources/api/v2/tube_rack_resource.rb index 72004ae850..675fca936a 100644 --- a/app/resources/api/v2/tube_rack_resource.rb +++ b/app/resources/api/v2/tube_rack_resource.rb @@ -29,17 +29,25 @@ class TubeRackResource < BaseResource # Associations: has_many :racked_tubes has_many :comments, readonly: true - has_one :purpose, foreign_key: :plate_purpose_id + # TODO: change to purpose_id + has_one :purpose, foreign_key: :plate_purpose_id, class_name: 'TubeRackPurpose' + has_many :parents, readonly: true, polymorphic: true + has_many :state_changes, readonly: true + has_one :custom_metadatum_collection, foreign_key_on: :related + has_many :ancestors, readonly: true, polymorphic: true + # NB. no child or descendent associations as tube racks can't have children (tubes have children). + # NB. no direct_submissions association as tube racks are currently not submitted. # Attributes attribute :uuid, readonly: true attribute :created_at, readonly: true attribute :updated_at, readonly: true attribute :labware_barcode, readonly: true + attribute :state, readonly: true attribute :size attribute :number_of_rows, readonly: true attribute :number_of_columns, readonly: true - attribute :name, readonly: true + attribute :name, delegate: :display_name, readonly: true attribute :tube_locations # Filters @@ -49,11 +57,16 @@ class TubeRackResource < BaseResource apply: ( lambda do |records, value, _options| - purpose = Purpose.find_by(name: value) + purpose = TubeRack::Purpose.find_by(name: value) records.where(plate_purpose_id: purpose) end ) filter :purpose_id, apply: ->(records, value, _options) { records.where(plate_purpose_id: value) } + filter :created_at_gt, + apply: lambda { |records, value, _options| records.where('labware.created_at > ?', value[0].to_date) } + filter :updated_at_gt, + apply: lambda { |records, value, _options| records.where('labware.updated_at > ?', value[0].to_date) } + # TODO: do we need scope for include_used? no direct child labwares here so would have to check for racked tubes # Class method overrides def fetchable_fields diff --git a/app/sample_manifest_excel/sample_manifest_excel/upload/processor/tube_rack.rb b/app/sample_manifest_excel/sample_manifest_excel/upload/processor/tube_rack.rb index a0bc7ce3d7..5c1deb019b 100644 --- a/app/sample_manifest_excel/sample_manifest_excel/upload/processor/tube_rack.rb +++ b/app/sample_manifest_excel/sample_manifest_excel/upload/processor/tube_rack.rb @@ -129,7 +129,7 @@ def create_tube_rack_if_not_existing(tube_rack_barcode) # rubocop:todo Metrics/M if barcode.nil? # TODO: Purpose should be set based on what's selected when generating the manifest # https://github.com/sanger/sequencescape/issues/2469 - purpose = Purpose.where(target_type: 'TubeRack', size: @rack_size).first + purpose = ::TubeRack::Purpose.where(target_type: 'TubeRack', size: @rack_size).first tube_rack = ::TubeRack.create!(size: @rack_size, plate_purpose_id: purpose&.id) barcode_format = Barcode.matching_barcode_format(tube_rack_barcode) diff --git a/config/initializers/tube_racks_config.rb b/config/initializers/tube_racks_config.rb new file mode 100644 index 0000000000..96bbb3aeef --- /dev/null +++ b/config/initializers/tube_racks_config.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Stores constants related to tube racks. +Rails.application.config.tube_racks_config = { + # Key for poly metadata to store the tube rack barcode on the tube rack instance + tube_rack_barcode_key: 'tube_rack_barcode' +}.freeze diff --git a/config/routes.rb b/config/routes.rb index 3b641ecd2b..d10c59d1be 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,6 +56,7 @@ jsonapi_resources :sample_manifests jsonapi_resources :sample_metadata jsonapi_resources :specific_tube_creations, except: %i[update] + jsonapi_resources :specific_tube_rack_creations, except: %i[update] jsonapi_resources :state_changes, except: %i[update] jsonapi_resources :studies jsonapi_resources :submission_templates diff --git a/db/migrate/20241003105631_add_specific_tube_rack_creation_purposes.rb b/db/migrate/20241003105631_add_specific_tube_rack_creation_purposes.rb new file mode 100644 index 0000000000..5a897f292d --- /dev/null +++ b/db/migrate/20241003105631_add_specific_tube_rack_creation_purposes.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +class AddSpecificTubeRackCreationPurposes < ActiveRecord::Migration[6.1] + def change + create_table :specific_tube_rack_creation_purposes do |t| + t.integer :specific_tube_rack_creation_id, null: false + t.integer :tube_rack_purpose_id, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20241003152322_add_specific_tube_rack_creation_children.rb b/db/migrate/20241003152322_add_specific_tube_rack_creation_children.rb new file mode 100644 index 0000000000..33c6eedc1a --- /dev/null +++ b/db/migrate/20241003152322_add_specific_tube_rack_creation_children.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +class AddSpecificTubeRackCreationChildren < ActiveRecord::Migration[6.1] + def change + create_table :specific_tube_rack_creation_children do |t| + t.integer :specific_tube_rack_creation_id, null: false + t.integer :tube_rack_id, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index f74311f721..b363df9640 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1449,6 +1449,20 @@ t.datetime "updated_at" end + create_table "specific_tube_rack_creation_children", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.integer "specific_tube_rack_creation_id", null: false + t.integer "tube_rack_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + + create_table "specific_tube_rack_creation_purposes", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.integer "specific_tube_rack_creation_id", null: false + t.integer "tube_rack_purpose_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + create_table "stamp_qcables", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", options: "ENGINE=InnoDB ROW_FORMAT=DYNAMIC", force: :cascade do |t| t.integer "stamp_id", null: false t.integer "qcable_id", null: false diff --git a/spec/factories/racked_tube.rb b/spec/factories/racked_tube.rb index 6a8a1337ca..ac804a1c3c 100644 --- a/spec/factories/racked_tube.rb +++ b/spec/factories/racked_tube.rb @@ -2,5 +2,8 @@ FactoryBot.define do factory :racked_tube do + tube + tube_rack + coordinate { 'A1' } end end diff --git a/spec/factories/specific_tube_rack_creation.rb b/spec/factories/specific_tube_rack_creation.rb new file mode 100644 index 0000000000..d1ffd10e34 --- /dev/null +++ b/spec/factories/specific_tube_rack_creation.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory(:specific_tube_rack_creation) do + transient do + tube_rack_purpose { create(:tube_rack_purpose) } + tube_purpose { create(:tube_purpose) } + parent_plate { create(:plate, :with_wells, sample_count: 1) } + end + + tube_rack_attributes do + [ + { + tube_rack_name: 'Tube Rack', + tube_rack_barcode: 'TR00000001', + tube_rack_purpose_uuid: tube_rack_purpose.uuid, + racked_tubes: [ + { + tube_barcode: 'ST00000001', + tube_name: 'SEQ:NT1A:A1', + tube_purpose_uuid: tube_purpose.uuid, + tube_position: 'A1', + parent_uuids: [parent_plate.wells.first.uuid] + } + ] + } + ] + end + + user { |target| target.association(:user) } + + # parent is expected to be a single plate + parents { |target| [target.association(:plate)] } + end +end diff --git a/spec/factories/tube_rack.rb b/spec/factories/tube_rack.rb index ac59d465fa..4993ba48b1 100644 --- a/spec/factories/tube_rack.rb +++ b/spec/factories/tube_rack.rb @@ -3,7 +3,11 @@ FactoryBot.define do factory :tube_rack do size { 96 } + + purpose factory: %i[tube_rack_purpose] + transient { barcode { create(:barcode) } } + after(:create) { |rack, evaluator| rack.barcodes << evaluator.barcode } factory :tube_rack_with_tubes do diff --git a/spec/lib/record_loader/tube_rack_purpose_loader_spec.rb b/spec/lib/record_loader/tube_rack_purpose_loader_spec.rb index 867a6b552f..23ed81cbab 100644 --- a/spec/lib/record_loader/tube_rack_purpose_loader_spec.rb +++ b/spec/lib/record_loader/tube_rack_purpose_loader_spec.rb @@ -27,11 +27,15 @@ it 'sets attributes on the created records' do record_loader.create! expect(TubeRack::Purpose.all).to include( - have_attributes(name: 'TR Stock 96', target_type: 'TubeRack', size: 96, prefix: 'TR', stock_plate: true), - have_attributes(name: 'TR Stock 48', target_type: 'TubeRack', size: 48, prefix: 'TR', stock_plate: true) + have_attributes(name: 'TR Stock 96', target_type: 'TubeRack', size: 96, stock_plate: true), + have_attributes(name: 'TR Stock 48', target_type: 'TubeRack', size: 48, stock_plate: true) ) end + it 'sets the prefix on all records' do + expect(TubeRack::Purpose.all.map(&:prefix)).to all(have_attributes(name: 'TR')) + end + it 'sets barcode printer type on all records' do expect(TubeRack::Purpose.all.map(&:barcode_printer_type)).to all(have_attributes(name: '96 Well Plate')) end diff --git a/spec/models/heron/factories/concerns/contents_spec.rb b/spec/models/heron/factories/concerns/contents_spec.rb index 64c4759bf0..6e512e5017 100644 --- a/spec/models/heron/factories/concerns/contents_spec.rb +++ b/spec/models/heron/factories/concerns/contents_spec.rb @@ -232,9 +232,8 @@ def recipients_key end context 'with a tube rack' do - let(:purpose) { create(:plate_purpose, target_type: 'Plate', name: 'Stock Plate', size: '96') } + let(:purpose) { create(:tube_rack_purpose, target_type: 'TubeRack', name: 'Stock Tube Rack', size: '96') } let(:tube_rack) { TubeRack.create!(size: '96', purpose: purpose) } - let(:plate) { tube_rack } let(:tubes) do %w[A1 B1 C1].map do |coordinate| tube = create(:tube) diff --git a/spec/models/specific_tube_rack_creation_spec.rb b/spec/models/specific_tube_rack_creation_spec.rb new file mode 100644 index 0000000000..c349a629c5 --- /dev/null +++ b/spec/models/specific_tube_rack_creation_spec.rb @@ -0,0 +1,673 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SpecificTubeRackCreation do + shared_context 'with common setup' do + subject(:specific_tube_rack_creation) { described_class.new(creation_parameters) } + + let(:child_tube_rack_purpose) { create(:tube_rack_purpose) } + let(:child_tube_purpose) { create(:sample_tube_purpose) } + let(:tube_rack_attributes) do + [ + { + tube_rack_name: 'Tube Rack', + tube_rack_barcode: 'TR00000001', + tube_rack_purpose_uuid: child_tube_rack_purpose.uuid, + racked_tubes: [ + { + tube_barcode: 'ST00000001', + tube_name: 'SEQ:NT1A:A1', + tube_purpose_uuid: child_tube_purpose.uuid, + tube_position: 'A1', + parent_uuids: [parent.uuid] + } + ] + } + ] + end + + let(:user) { create(:user) } + let(:parent) { create(:plate) } + end + + shared_context 'with common test setup' do + before do + expect(specific_tube_rack_creation.save).to (be true), + -> { "Failed to save: #{specific_tube_rack_creation.errors.full_messages}" } + end + + let(:first_child_rack) { specific_tube_rack_creation.children.first } + end + + shared_examples 'with common tests' do + it 'creates one child' do + expect(specific_tube_rack_creation.children.count).to eq 1 + end + + it 'creates a tube rack' do + expect(first_child_rack).to be_a TubeRack + end + + it 'sets the purpose' do + expect(first_child_rack.purpose).to eq child_tube_rack_purpose + end + + it 'sets plates as parents' do + specific_tube_rack_creation.children.each { |child| expect(child.parents).to include(parent) } + end + end + + shared_examples 'a specific tube rack creator' do + include_context 'with common setup' + + describe '#save' do + include_context 'with common test setup' + include_examples 'with common tests' + end + end + + context 'with no tubes' do + include_context 'with common setup' + + let(:creation_parameters) { { user:, tube_rack_attributes:, parent: } } + + context 'with one rack and zero tubes' do + let(:tube_rack_attributes) do + [ + { + tube_rack_name: 'Tube Rack', + tube_rack_barcode: 'TR00000001', + tube_rack_purpose_uuid: child_tube_rack_purpose.uuid, + racked_tubes: [] + } + ] + end + + it_behaves_like 'a specific tube rack creator' + end + + context 'with two racks and zero tubes' do + let(:tube_rack_attributes) do + [ + { + tube_rack_name: 'Tube Rack', + tube_rack_barcode: 'TR00000001', + tube_rack_purpose_uuid: child_tube_rack_purpose.uuid, + racked_tubes: [] + }, + { + tube_rack_name: 'Tube Rack', + tube_rack_barcode: 'TR00000002', + tube_rack_purpose_uuid: child_tube_rack_purpose.uuid, + racked_tubes: [] + } + ] + end + + it_behaves_like 'a specific tube rack creator' + end + + context 'with an unknown tube rack purpose' do + let(:unknown_tube_rack_purpose_uuid) { 'unknown-tube-rack-purpose-uuid' } + let(:tube_rack_attributes) do + [ + { + tube_rack_name: 'Tube Rack', + tube_rack_barcode: 'TR00000001', + tube_rack_purpose_uuid: unknown_tube_rack_purpose_uuid, + racked_tubes: [] + } + ] + end + + let(:expected_error_msg) { "The tube rack purpose with UUID '#{unknown_tube_rack_purpose_uuid}' was not found." } + + it 'rejects the save if the tube rack purpose is not recognised' do + expect { specific_tube_rack_creation.save }.to raise_error(StandardError, expected_error_msg) + end + end + end + + context 'with tubes' do + include_context 'with common setup' + + let(:creation_parameters) { { user:, tube_rack_attributes:, parent: } } + + context 'with one rack and one tube' do + include_context 'with common test setup' + + let(:tube_rack_attributes) do + [ + { + tube_rack_name: 'Tube Rack', + tube_rack_barcode: 'TR00000001', + tube_rack_purpose_uuid: child_tube_rack_purpose.uuid, + racked_tubes: [ + { + tube_barcode: 'ST00000001', + tube_name: 'SEQ:NT1A:A1', + tube_purpose_uuid: child_tube_purpose.uuid, + tube_position: 'A1', + parent_uuids: [parent.uuid] + } + ] + } + ] + end + + it_behaves_like 'a specific tube rack creator' + + it 'creates a linked racked tube' do + expect(first_child_rack.tubes.count).to eq 1 + end + + it 'sets the tube purpose' do + expect(first_child_rack.tubes.first.purpose).to eq child_tube_purpose + end + + it 'sets the tube name' do + expect(first_child_rack.tubes.first.name).to eq 'SEQ:NT1A:A1' + end + + it 'sets the tube barcode' do + expect(first_child_rack.tubes.first.primary_barcode.barcode).to eq 'ST00000001' + end + + it 'sets the tube as a racked tube of the tube rack' do + expect(first_child_rack.racked_tubes.first.tube_id).to eq first_child_rack.tubes.first.id + end + + it 'sets the tube coordinate in the rack' do + expect(first_child_rack.racked_tubes.first.coordinate).to eq 'A1' + end + end + + context 'with one rack and multiple tubes' do + include_context 'with common test setup' + + let(:tube_rack_attributes) do + [ + { + tube_rack_name: 'Tube Rack', + tube_rack_barcode: 'TR00000001', + tube_rack_purpose_uuid: child_tube_rack_purpose.uuid, + racked_tubes: [ + { + tube_barcode: 'ST00000001', + tube_name: 'SEQ:NT1A:A1', + tube_purpose_uuid: child_tube_purpose.uuid, + tube_position: 'A1', + parent_uuids: [parent.uuid] + }, + { + tube_barcode: 'ST00000002', + tube_name: 'SEQ:NT2B:B1', + tube_purpose_uuid: child_tube_purpose.uuid, + tube_position: 'B1', + parent_uuids: [parent.uuid] + }, + { + tube_barcode: 'ST00000003', + tube_name: 'SEQ:NT3C:C1', + tube_purpose_uuid: child_tube_purpose.uuid, + tube_position: 'C1', + parent_uuids: [parent.uuid] + } + ] + } + ] + end + + it_behaves_like 'a specific tube rack creator' + + it 'creates multiple linked racked tubes' do + expect(first_child_rack.tubes.count).to eq 3 + end + + it 'sets the tube purpose' do + expect(first_child_rack.tubes.last.purpose).to eq child_tube_purpose + end + + it 'sets the tube name' do + expect(first_child_rack.tubes.last.name).to eq 'SEQ:NT3C:C1' + end + + it 'sets the tube barcode' do + expect(first_child_rack.tubes.first.primary_barcode.barcode).to eq 'ST00000001' + end + + it 'sets the tubes as racked tubes of the tube rack' do + expect(first_child_rack.racked_tubes.last.tube_id).to eq first_child_rack.tubes.last.id + end + + it 'sets the tube coordinate in the rack' do + expect(first_child_rack.racked_tubes.last.coordinate).to eq 'C1' + end + end + + context 'with two racks and one tube each' do + include_context 'with common test setup' + + let(:child_tube_rack_purpose2) { create(:tube_rack_purpose, name: 'SPR Rack Purpose') } + let(:child_tube_purpose2) { create(:sample_tube_purpose, name: 'SPR Tube Purpose') } + let(:last_child_rack) { specific_tube_rack_creation.children.last } + + let(:tube_rack_attributes) do + [ + { + tube_rack_name: 'Tube Rack', + tube_rack_barcode: 'TR00000001', + tube_rack_purpose_uuid: child_tube_rack_purpose.uuid, + racked_tubes: [ + { + tube_barcode: 'ST00000001', + tube_name: 'SEQ:NT1A:A1', + tube_purpose_uuid: child_tube_purpose.uuid, + tube_position: 'A1', + parent_uuids: [parent.uuid] + } + ] + }, + { + tube_rack_name: 'Tube Rack', + tube_rack_barcode: 'TR00000002', + tube_rack_purpose_uuid: child_tube_rack_purpose2.uuid, + racked_tubes: [ + { + tube_barcode: 'ST00000002', + tube_name: 'SPR:NT4D:D1', + tube_purpose_uuid: child_tube_purpose2.uuid, + tube_position: 'D1', + parent_uuids: [parent.uuid] + } + ] + } + ] + end + + it_behaves_like 'a specific tube rack creator' + + it 'creates a linked racked tube for the first rack' do + expect(first_child_rack.tubes.count).to eq 1 + end + + it 'creates a linked racked tube for the second rack' do + expect(last_child_rack.tubes.count).to eq 1 + end + + it 'sets the tube purpose for the tube in the first rack' do + expect(first_child_rack.tubes.last.purpose).to eq child_tube_purpose + end + + it 'sets the tube purpose for the tube in the second rack' do + expect(last_child_rack.tubes.last.purpose).to eq child_tube_purpose2 + end + + it 'sets the name of the tube in the first rack' do + expect(first_child_rack.tubes.first.name).to eq 'SEQ:NT1A:A1' + end + + it 'sets the name of the tube in the second rack' do + expect(last_child_rack.tubes.first.name).to eq 'SPR:NT4D:D1' + end + + it 'sets the barcode of the tube in the first rack' do + expect(first_child_rack.tubes.first.primary_barcode.barcode).to eq 'ST00000001' + end + + it 'sets the barcode of the tube in the second rack' do + expect(last_child_rack.tubes.first.primary_barcode.barcode).to eq 'ST00000002' + end + + it 'sets the racked tubes of the first tube rack' do + expect(first_child_rack.racked_tubes.first.tube_id).to eq first_child_rack.tubes.first.id + end + + it 'sets the racked tubes of the second tube rack' do + expect(last_child_rack.racked_tubes.first.tube_id).to eq last_child_rack.tubes.first.id + end + + it 'sets the coordinate of the tube in the first rack' do + expect(first_child_rack.racked_tubes.first.coordinate).to eq 'A1' + end + + it 'sets the coordinate of the tube in the second rack' do + expect(last_child_rack.racked_tubes.first.coordinate).to eq 'D1' + end + end + + context 'with an unknown tube purpose' do + let(:unknown_tube_purpose_uuid) { 'unknown-tube-purpose-uuid' } + let(:tube_rack_attributes) do + [ + { + tube_rack_name: 'Tube Rack', + tube_rack_barcode: 'TR00000001', + tube_rack_purpose_uuid: child_tube_rack_purpose.uuid, + racked_tubes: [ + { + tube_barcode: 'ST00000001', + tube_name: 'SEQ:NT1A:A1', + tube_purpose_uuid: unknown_tube_purpose_uuid, + tube_position: 'A1', + parent_uuids: [parent.uuid] + } + ] + } + ] + end + + let(:expected_error_msg) { "The tube purpose with UUID '#{unknown_tube_purpose_uuid}' was not found." } + + it 'rejects the save if the tube purpose is not recognised' do + expect { specific_tube_rack_creation.save }.to raise_error(StandardError, expected_error_msg) + end + end + end + + context 'when testing individual methods' do + include_context 'with common setup' + + let(:creation_parameters) { { user:, tube_rack_attributes:, parent: } } + + let(:existing_tube_rack) { create(:tube_rack, name: 'TubeRack2') } + let(:new_tube_rack_barcode_string) { 'TR10000001' } + let(:new_tube_rack) { create(:tube_rack, name: 'TubeRack1') } + let(:tube_rack_barcode_format) { :fluidx_barcode } + + describe '#handle_tube_rack_barcode' do + before { allow(Barcode).to receive(:includes).with(:asset).and_return(Barcode) } + + context 'when the existing barcode record is nil' do + let(:existing_tube_rack_barcode_record) { nil } + let(:new_tube_rack_barcode_record) do + create(:barcode, barcode: new_tube_rack_barcode_string, format: tube_rack_barcode_format) + end + + before do + allow(Barcode).to receive(:find_by).with(barcode: new_tube_rack_barcode_string).and_return(nil) + allow(Barcode).to receive(:create!).with( + labware: new_tube_rack, + barcode: new_tube_rack_barcode_string, + format: tube_rack_barcode_format + ).and_return(new_tube_rack_barcode_record) + # rubocop:disable RSpec/SubjectStub + # rubocop doesn't understand we aren't stubbing the method + allow(specific_tube_rack_creation).to receive(:create_new_barcode).and_call_original + # rubocop:enable RSpec/SubjectStub + end + + it 'calls create_new_barcode with the correct arguments' do + specific_tube_rack_creation.send(:handle_tube_rack_barcode, new_tube_rack_barcode_string, new_tube_rack) + + # rubocop:disable RSpec/SubjectStub + expect(specific_tube_rack_creation).to have_received(:create_new_barcode).with( + new_tube_rack_barcode_string, + new_tube_rack + ) + # rubocop:enable RSpec/SubjectStub + end + end + + context 'when the barcode record is already in use' do + let(:existing_tube_rack_barcode_string) { 'TR10000001' } + let(:existing_tube_rack_barcode_record) do + create(:barcode, barcode: existing_tube_rack_barcode_string, labware: existing_tube_rack) + end + + before do + allow(Barcode).to receive(:find_by).with(barcode: new_tube_rack_barcode_string).and_return( + existing_tube_rack_barcode_record + ) + # rubocop:disable RSpec/SubjectStub + # rubocop doesn't understand we aren't stubbing the method + allow(specific_tube_rack_creation).to receive(:redirect_existing_barcode).and_call_original + # rubocop:enable RSpec/SubjectStub + end + + # rubocop:disable RSpec/ExampleLength + it 'calls redirect_existing_barcode with the correct arguments' do + specific_tube_rack_creation.send(:handle_tube_rack_barcode, existing_tube_rack_barcode_string, new_tube_rack) + + # rubocop:disable RSpec/SubjectStub + expect(specific_tube_rack_creation).to have_received(:redirect_existing_barcode).with( + existing_tube_rack_barcode_record, + new_tube_rack, + existing_tube_rack_barcode_string + ) + # rubocop:enable RSpec/SubjectStub + end + # rubocop:enable RSpec/ExampleLength + end + end + + describe '#create_new_barcode' do + let(:tube_rack_barcode_format) { :test_format } + + before do + allow(Barcode).to receive(:matching_barcode_format).with(new_tube_rack_barcode_string).and_return( + tube_rack_barcode_format + ) + allow(Barcode).to receive(:create!) + end + + context 'when the barcode format is recognized' do + it 'creates a new barcode with the correct attributes' do + specific_tube_rack_creation.send(:create_new_barcode, new_tube_rack_barcode_string, new_tube_rack) + + expect(Barcode).to have_received(:create!).with( + labware: new_tube_rack, + barcode: new_tube_rack_barcode_string, + format: tube_rack_barcode_format + ) + end + end + + context 'when the barcode format is not recognized' do + let(:tube_rack_barcode_format) { nil } + + # rubocop:disable RSpec/ExampleLength + it 'raises a StandardError with the correct message' do + expect do + specific_tube_rack_creation.send(:create_new_barcode, new_tube_rack_barcode_string, new_tube_rack) + end.to raise_error( + StandardError, + "The tube rack barcode '#{new_tube_rack_barcode_string}' is not a recognised format." + ) + end + # rubocop:enable RSpec/ExampleLength + end + end + + describe '#redirect_existing_barcode' do + let(:existing_tube_rack_barcode_record) { create(:barcode, labware: existing_labware) } + + context 'when the existing labware is a TubeRack' do + let(:existing_labware) { create(:tube_rack) } + + before { allow(existing_tube_rack_barcode_record).to receive(:labware=).with(new_tube_rack) } + + # rubocop:disable RSpec/ExampleLength + it 'redirects the barcode to the new tube rack' do + specific_tube_rack_creation.send( + :redirect_existing_barcode, + existing_tube_rack_barcode_record, + new_tube_rack, + new_tube_rack_barcode_string + ) + + expect(existing_tube_rack_barcode_record).to have_received(:labware=).with(new_tube_rack) + end + # rubocop:enable RSpec/ExampleLength + end + + context 'when the existing labware is not a TubeRack' do + let(:existing_labware) { create(:plate) } + + # rubocop:disable RSpec/ExampleLength + it 'raises a StandardError with the correct message' do + expect do + specific_tube_rack_creation.send( + :redirect_existing_barcode, + existing_tube_rack_barcode_record, + new_tube_rack, + new_tube_rack_barcode_string + ) + end.to raise_error( + StandardError, + "The tube rack barcode '#{new_tube_rack_barcode_string}' is already in use " \ + 'by another type of labware, cannot create tube rack.' + ) + end + # rubocop:enable RSpec/ExampleLength + end + end + + describe '#add_tube_rack_metadata' do + let(:metadata_key) { 'tube_rack_barcode_key' } + let(:poly_metadatum) do + create(:poly_metadatum, metadatable: new_tube_rack, key: metadata_key, value: new_tube_rack_barcode_string) + end + + before do + allow(Rails.application.config).to receive(:tube_racks_config).and_return(tube_rack_barcode_key: metadata_key) + allow(PolyMetadatum).to receive(:new).and_return(poly_metadatum) + allow(poly_metadatum).to receive(:save).and_return(save_result) + end + + context 'when the metadata saves successfully' do + let(:save_result) { true } + + it 'creates a new PolyMetadatum with the correct attributes' do + specific_tube_rack_creation.send(:add_tube_rack_metadata, new_tube_rack_barcode_string, new_tube_rack) + + expect(PolyMetadatum).to have_received(:new).with( + key: metadata_key, + value: new_tube_rack_barcode_string, + metadatable_type: 'TubeRack', + metadatable_id: new_tube_rack.id + ) + end + end + + context 'when the metadata does not save successfully' do + let(:save_result) { false } + + # rubocop:disable RSpec/ExampleLength + it 'raises a StandardError with the correct message' do + expect do + specific_tube_rack_creation.send(:add_tube_rack_metadata, new_tube_rack_barcode_string, new_tube_rack) + end.to raise_error( + StandardError, + "New metadata for tube rack (key: #{metadata_key}, value: #{new_tube_rack_barcode_string}) did not save" + ) + end + # rubocop:enable RSpec/ExampleLength + end + end + + describe '#ensure_unique_tube_barcode' do + let(:tube_barcode) { 'TB123456' } + let(:existing_tube_barcode_record) { create(:barcode) } + + before do + allow(Barcode).to receive(:includes).with(:asset).and_return(Barcode) + allow(Barcode).to receive(:find_by).with(asset_id: tube_barcode).and_return(existing_tube_barcode_record) + end + + context 'when the tube barcode is not in use' do + let(:existing_tube_barcode_record) { nil } + + it 'does not raise an error' do + expect { specific_tube_rack_creation.send(:ensure_unique_tube_barcode, tube_barcode) }.not_to raise_error + end + end + + context 'when the tube barcode is already in use' do + it 'raises a StandardError with the correct message' do + expect { specific_tube_rack_creation.send(:ensure_unique_tube_barcode, tube_barcode) }.to raise_error( + StandardError, + "The tube barcode '#{tube_barcode}' is already in use, cannot continue." + ) + end + end + end + + describe '#check_tube_barcode_format' do + let(:tube_barcode) { 'TB123456' } + + before { allow(Barcode).to receive(:matching_barcode_format).with(tube_barcode).and_return(barcode_format) } + + context 'when the barcode format is not recognized' do + let(:barcode_format) { nil } + + it 'raises a StandardError with the correct message' do + expect { specific_tube_rack_creation.send(:check_tube_barcode_format, tube_barcode) }.to raise_error( + StandardError, + "The tube barcode '#{tube_barcode}' is not a recognised format." + ) + end + end + + context 'when the barcode format is recognized but not fluidx' do + let(:barcode_format) { :other_format } + + it 'raises a StandardError with the correct message' do + expect { specific_tube_rack_creation.send(:check_tube_barcode_format, tube_barcode) }.to raise_error( + StandardError, + "The tube barcode '#{tube_barcode}' is not of the expected fluidx type." + ) + end + end + + context 'when the barcode format is fluidx' do + let(:barcode_format) { :fluidx_barcode } + + it 'does not raise an error' do + expect { specific_tube_rack_creation.send(:check_tube_barcode_format, tube_barcode) }.not_to raise_error + end + end + end + + describe '#link_tube_to_rack' do + let(:tube) { create(:tube, name: 'Tube1') } + let(:tube_position) { 'A1' } + let(:racked_tube) { create(:racked_tube, tube: tube, tube_rack: new_tube_rack, coordinate: tube_position) } + + before do + allow(RackedTube).to receive(:new).and_return(racked_tube) + allow(racked_tube).to receive(:save!).and_return(save_result) + end + + context 'when the racked tube saves successfully' do + let(:save_result) { true } + + it 'creates a new RackedTube with the correct attributes' do + specific_tube_rack_creation.send(:link_tube_to_rack, tube, new_tube_rack, tube_position) + + expect(RackedTube).to have_received(:new).with( + tube: tube, + tube_rack: new_tube_rack, + coordinate: tube_position + ) + end + end + + context 'when the racked tube does not save successfully' do + let(:save_result) { false } + + # rubocop:disable RSpec/ExampleLength + it 'raises an StandardError with the correct message' do + expect do + specific_tube_rack_creation.send(:link_tube_to_rack, tube, new_tube_rack, tube_position) + end.to raise_error( + StandardError, + "The tube 'Tube1' could not be linked to the tube rack 'TubeRack1' at position 'A1'." + ) + end + # rubocop:enable RSpec/ExampleLength + end + end + end +end diff --git a/spec/requests/api/v2/specific_tube_rack_creations_spec.rb b/spec/requests/api/v2/specific_tube_rack_creations_spec.rb new file mode 100644 index 0000000000..0ecdf6d510 --- /dev/null +++ b/spec/requests/api/v2/specific_tube_rack_creations_spec.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +require 'rails_helper' +require './spec/requests/api/v2/shared_examples/api_key_authenticatable' +require './spec/requests/api/v2/shared_examples/post_requests' + +# rubocop:disable RSpec/MultipleExpectations +describe 'Specific Tube Rack Creations API', with: :api_v2 do + let(:model_class) { SpecificTubeRackCreation } + let(:resource_type) { model_class.name.demodulize.pluralize.underscore } + let(:base_endpoint) { "/api/v2/#{resource_type}" } + let(:user) { create(:user) } + + let(:basic_tube_rack_attributes) do + [ + { + tube_rack_name: 'Tube Rack 1', + tube_rack_barcode: 'TR00000001', + tube_rack_purpose_uuid: tube_rack_purpose.uuid, + racked_tubes: [ + { + tube_barcode: 'ST00000001', + tube_name: 'SEQ:NT1A:A1', + tube_purpose_uuid: tube_purpose.uuid, + tube_position: 'A1', + parent_uuids: [parent_plate.uuid] + } + ] + } + ] + end + let(:tube_purpose) { create(:tube_purpose, name: 'example-tube-purpose-uuid') } + let(:tube_rack_purpose) { create(:tube_rack_purpose, name: 'example-tube-rack-purpose-uuid') } + let(:parent_plate) { create(:plate, well_count: 1) } + + it_behaves_like 'ApiKeyAuthenticatable' + + context 'with a list of resources' do + before { create(:specific_tube_rack_creation, tube_rack_attributes: basic_tube_rack_attributes) } + + describe '#GET all resources' do + before { api_get base_endpoint } + + it 'responds with a success http code' do + expect(response).to have_http_status(:success) + end + + it 'returns all the resources' do + expect(json['data'].length).to eq(1) + end + end + end + + describe '#GET with a single resource' do + let(:parents) { [create(:plate)] } + let(:resource) { create(:specific_tube_rack_creation, tube_rack_attributes: basic_tube_rack_attributes) } + + context 'without included relationships' do + before { api_get "#{base_endpoint}/#{resource.id}" } + + it 'responds with a success http code' do + expect(response).to have_http_status(:success) + end + + it 'returns the correct resource' do + expect(json.dig('data', 'id')).to eq(resource.id.to_s) + expect(json.dig('data', 'type')).to eq(resource_type) + end + + it 'returns the correct attributes' do + expect(json.dig('data', 'attributes', 'uuid')).to eq(resource.uuid) + end + + it 'excludes unfetchable attributes' do + expect(json.dig('data', 'attributes', 'parent_uuids')).not_to be_present + expect(json.dig('data', 'attributes', 'tube_rack_attributes')).not_to be_present + end + + it 'returns references to related resources' do + expect(json.dig('data', 'relationships', 'children')).to be_present + expect(json.dig('data', 'relationships', 'parents')).to be_present + expect(json.dig('data', 'relationships', 'user')).to be_present + end + + it 'does not include attributes for related resources' do + expect(json['included']).not_to be_present + end + end + + context 'with included relationships' do + context 'with children' do + let(:related_name) { 'children' } + + it_behaves_like 'a POST request including a has_many relationship' + end + + context 'with parents' do + let(:related_name) { 'parents' } + + it_behaves_like 'a POST request including a has_many relationship' + end + + context 'with user' do + let(:related_name) { 'user' } + + it_behaves_like 'a POST request including a has_one relationship' + end + end + end + + describe '#PATCH a resource' do + let(:resource_model) { create(:specific_tube_rack_creation, tube_rack_attributes: basic_tube_rack_attributes) } + let(:payload) { { data: { id: resource_model.id, type: resource_type, attributes: {} } } } + + it 'finds no route for the method' do + expect { api_patch "#{base_endpoint}/#{resource_model.id}", payload }.to raise_error( + ActionController::RoutingError + ) + end + end + + describe '#POST a create request' do + let(:parents) { [create(:plate)] } + let(:user) { create(:user) } + + let(:base_attributes) { { tube_rack_attributes: basic_tube_rack_attributes } } + + let(:parents_relationship) { { data: parents.map { |p| { id: p.id, type: 'labware' } } } } + let(:user_relationship) { { data: { id: user.id, type: 'users' } } } + + # expected tube rack purposes from the attributes + let(:expected_child_purposes) do + basic_tube_rack_attributes.map { |attr| TubeRack::Purpose.with_uuid(attr[:tube_rack_purpose_uuid]).first } + end + + context 'with a valid payload' do + shared_examples 'a valid request' do + it 'creates a new resource' do + expect { api_post base_endpoint, payload }.to change(model_class, :count).by(1) + end + + context 'when a resource has been made' do + before { api_post base_endpoint, payload } + + it 'responds with success' do + expect(response).to have_http_status(:success) + end + + it 'responds with the correct attributes' do + new_record = model_class.last + + expect(json.dig('data', 'type')).to eq(resource_type) + expect(json.dig('data', 'attributes', 'uuid')).to eq(new_record.uuid) + end + + it 'excludes unfetchable attributes' do + expect(json.dig('data', 'attributes', 'parent_uuids')).not_to be_present + expect(json.dig('data', 'attributes', 'tube_rack_attributes')).not_to be_present + end + + it 'returns references to related resources' do + expect(json.dig('data', 'relationships', 'parents')).to be_present + expect(json.dig('data', 'relationships', 'user')).to be_present + end + + it 'applies the attributes to the new record' do + new_record = model_class.last + + # Note that the tube_rack_attributes from the queried record will not match the submitted values, + # but it will consist of empty hashes equalling the number of child purposes, as defined in the model. + expect(new_record.tube_rack_attributes).to eq(Array.new(expected_child_purposes.length, {})) + end + + it 'applies the relationships to the new record' do + new_record = model_class.last + + expect(new_record.child_purposes).to eq(expected_child_purposes) + expect(new_record.parents).to eq(parents) + expect(new_record.user).to eq(user) + end + + it 'generated children with valid attributes' do + new_record = model_class.last + + expect(new_record.children.length).to eq(1) + expect(new_record.children.map(&:name)).to eq(['Tube Rack 1']) + + expect(new_record.children.map(&:purpose)).to eq(expected_child_purposes) + end + end + end + + context 'with complete attributes' do + let(:payload) do + { + data: { + type: resource_type, + attributes: base_attributes.merge({ parent_uuids: parents.map(&:uuid), user_uuid: user.uuid }) + } + } + end + + it_behaves_like 'a valid request' + end + + context 'with relationships' do + let(:payload) do + { + data: { + type: resource_type, + attributes: base_attributes, + relationships: { + parents: parents_relationship, + user: user_relationship + } + } + } + end + + it_behaves_like 'a valid request' + end + + context 'with conflicting relationships' do + let(:other_parents) { create_list(:plate, 2) } + let(:other_user) { create(:user) } + let(:payload) do + { + data: { + type: resource_type, + attributes: + base_attributes.merge({ parent_uuids: other_parents.map(&:uuid), user_uuid: other_user.uuid }), + relationships: { + parents: parents_relationship, + user: user_relationship + } + } + } + end + + # This test should pass because the relationships are preferred over the attributes. + it_behaves_like 'a valid request' + end + end + + context 'with a read-only attribute in the payload' do + context 'with uuid' do + let(:disallowed_attribute) { 'uuid' } + let(:payload) do + { + data: { + type: resource_type, + attributes: base_attributes.merge({ uuid: '111111-2222-3333-4444-555555666666' }) + } + } + end + + it_behaves_like 'a POST request with a disallowed attribute' + end + end + + context 'without a required relationship' do + context 'without parent_uuids' do + let(:error_detail_message) { "parent - can't be blank" } + let(:payload) { { data: { type: resource_type, attributes: base_attributes.merge({ user_uuid: user.uuid }) } } } + + it_behaves_like 'an unprocessable POST request with a specific error' + end + + context 'without user_uuid' do + let(:error_detail_message) { "user - can't be blank" } + let(:payload) do + { data: { type: resource_type, attributes: base_attributes.merge({ parent_uuids: parents.map(&:uuid) }) } } + end + + it_behaves_like 'an unprocessable POST request with a specific error' + end + + context 'without parents' do + let(:error_detail_message) { "parent - can't be blank" } + let(:payload) do + { data: { type: resource_type, attributes: base_attributes, relationships: { user: user_relationship } } } + end + + it_behaves_like 'an unprocessable POST request with a specific error' + end + + context 'without user' do + let(:error_detail_message) { "user - can't be blank" } + let(:payload) do + { + data: { + type: resource_type, + attributes: base_attributes, + relationships: { + parents: parents_relationship + } + } + } + end + + it_behaves_like 'an unprocessable POST request with a specific error' + end + end + end +end + +# rubocop:enable RSpec/MultipleExpectations diff --git a/spec/requests/api/v2/tube_rack_purposes_spec.rb b/spec/requests/api/v2/tube_rack_purposes_spec.rb index 8e5d98bc3e..6dcec60ba3 100644 --- a/spec/requests/api/v2/tube_rack_purposes_spec.rb +++ b/spec/requests/api/v2/tube_rack_purposes_spec.rb @@ -3,6 +3,7 @@ require 'rails_helper' require './spec/requests/api/v2/shared_examples/api_key_authenticatable' +# rubocop:disable RSpec/ExampleLength, RSpec/MultipleExpectations describe 'Tube Rack Purposes API', with: :api_v2 do let(:base_endpoint) { '/api/v2/tube_rack_purposes' } @@ -11,10 +12,15 @@ describe '#get all Tube Rack Purposes' do before { create_list(:tube_rack_purpose, 5) } - it 'returns the list of Tube Rack Purposes' do + it 'returns a successful response' do api_get base_endpoint expect(response).to have_http_status(:success) + end + + it 'returns the list of Tube Rack Purposes' do + api_get base_endpoint + expect(json['data'].length).to eq(5) end end @@ -27,6 +33,7 @@ expect(response).to have_http_status(:success) expect(json.dig('data', 'type')).to eq('tube_rack_purposes') expect(json.dig('data', 'attributes', 'name')).to eq(resource_model.name) + expect(json.dig('data', 'attributes', 'purpose_type')).to eq(resource_model.type) expect(json.dig('data', 'attributes', 'size')).to eq(resource_model.size) expect(json.dig('data', 'attributes', 'target_type')).to eq(resource_model.target_type) expect(json.dig('data', 'attributes', 'uuid')).to eq(resource_model.uuid) @@ -201,3 +208,5 @@ end end end + +# rubocop:enable RSpec/ExampleLength, RSpec/MultipleExpectations diff --git a/spec/resources/api/v2/specific_tube_rack_creation_resource_spec.rb b/spec/resources/api/v2/specific_tube_rack_creation_resource_spec.rb new file mode 100644 index 0000000000..f28bdf2fc6 --- /dev/null +++ b/spec/resources/api/v2/specific_tube_rack_creation_resource_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'rails_helper' +require './app/resources/api/v2/specific_tube_rack_creation_resource' + +RSpec.describe Api::V2::SpecificTubeRackCreationResource, type: :resource do + subject(:resource) { described_class.new(resource_model, {}) } + + let(:resource_model) { build_stubbed(:specific_tube_rack_creation) } + + # Attributes + it { is_expected.to have_readonly_attribute :uuid } + + it { is_expected.to have_writeonly_attribute :parent_uuids } + it { is_expected.to have_writeonly_attribute :tube_rack_attributes } + it { is_expected.to have_writeonly_attribute :user_uuid } + + # Relationships + it { is_expected.to have_many(:children).with_class_name('TubeRack') } + it { is_expected.to have_many(:parents).with_class_name('Labware') } + it { is_expected.to have_one(:user).with_class_name('User') } +end diff --git a/spec/resources/api/v2/tube_rack_purpose_resource_spec.rb b/spec/resources/api/v2/tube_rack_purpose_resource_spec.rb index de9632a346..fbea17eee5 100644 --- a/spec/resources/api/v2/tube_rack_purpose_resource_spec.rb +++ b/spec/resources/api/v2/tube_rack_purpose_resource_spec.rb @@ -6,9 +6,10 @@ RSpec.describe Api::V2::TubeRackPurposeResource, type: :resource do subject(:resource) { described_class.new(resource_model, {}) } - let(:resource_model) { build_stubbed :tube_rack_purpose } + let(:resource_model) { build_stubbed(:tube_rack_purpose) } # Test attributes + # rubocop:disable RSpec/ExampleLength it 'has the expected attributes', :aggregate_failures do expect(resource).not_to have_attribute :id expect(resource).to have_attribute :name @@ -26,4 +27,6 @@ expect(resource).to have_updatable_field :size expect(resource).not_to have_updatable_field :uuid end + + # rubocop:enable RSpec/ExampleLength end diff --git a/spec/resources/api/v2/tube_rack_resource_spec.rb b/spec/resources/api/v2/tube_rack_resource_spec.rb index 89e9f4f6c5..4d8ec4a315 100644 --- a/spec/resources/api/v2/tube_rack_resource_spec.rb +++ b/spec/resources/api/v2/tube_rack_resource_spec.rb @@ -32,7 +32,7 @@ # eg. it { is_expected.to have_many(:samples).with_class_name('Sample') } it 'exposes associations', :aggregate_failures do expect(tube_rack).to have_many(:racked_tubes).with_class_name('RackedTube') - expect(tube_rack).to have_one(:purpose).with_class_name('Purpose') + expect(tube_rack).to have_one(:purpose).with_class_name('TubeRackPurpose') expect(tube_rack).to have_one(:comments).with_class_name('Comment') end @@ -70,4 +70,25 @@ end end end + + describe 'filters' do + let(:purpose) { create(:tube_rack_purpose, name: 'Test Purpose') } + let(:other_purpose) { create(:tube_rack_purpose, name: 'Other Purpose') } + let!(:tube_rack_with_purpose) { create(:tube_rack, purpose:) } + let!(:tube_rack_with_other_purpose) { create(:tube_rack, purpose: other_purpose) } + + describe 'purpose_name' do + it 'filters tube racks by purpose name' do + records = described_class.apply_filters(TubeRack.all, { purpose_name: 'Test Purpose' }, {}) + + expect(records).to include(tube_rack_with_purpose) + expect(records).not_to include(tube_rack_with_other_purpose) + end + + it 'returns no records if the purpose name does not match' do + records = described_class.apply_filters(TubeRack.all, { purpose_name: 'Nonexistent Purpose' }, {}) + expect(records).to be_empty + end + end + end end