diff --git a/app/controllers/publications_controller.rb b/app/controllers/publications_controller.rb index 0ea9a911..e5d97f75 100644 --- a/app/controllers/publications_controller.rb +++ b/app/controllers/publications_controller.rb @@ -51,41 +51,54 @@ def edit # POST /publications def create - @publication = Publication.by_doi(params['publication']['doi']) - if @publication.new_record? - render('new') + if params['publication']['doi'].blank? && current_user.try(:curator?) + @publication = Publication.create_without_doi( + publication_manual_params, + authors: params[:authors_given].to_a.zip(params[:authors_family].to_a) + ) + return render('new') unless @publication.persisted? else - if params[:link_name] && params[:link_name][:id] - @name = Name.find(params[:link_name][:id]) - par = { publication: @publication, name: @name } - pn = PublicationName.find_or_create_by(par) + doi = params['publication']['doi'] + # by_autocomplete resolves either a raw DOI (falling through to + # by_doi, so "type a fresh DOI to register it" keeps working + # unchanged) or a doi_title-style "key: title" string from selecting + # an existing publication in the autocomplete dropdown — including a + # DOI-less one via its "id:" key. Only falls back to by_doi + # directly for blank input, matching its pre-existing error message. + @publication = Publication.by_autocomplete(doi) || Publication.by_doi(doi) + return render('new') if @publication.new_record? + end + + if params[:link_name] && params[:link_name][:id] + @name = Name.find(params[:link_name][:id]) + par = { publication: @publication, name: @name } + pn = PublicationName.find_or_create_by(par) - case params[:link_name][:as] - when 'propose' - @name.update(proposed_in: @publication) - flash[:notice] = 'Effective publication registered' - when 'not_valid_proposal' - pn.update(not_valid_proposal: true) - flash[:notice] = - 'Original (not valid) publication registered' - when 'corrig' - redirect_to( - corrigendum_in_name_url(@name, publication_id: @publication.id) - ) - return - when 'assign' - @name.update(assigned_in: @publication) - flash[:notice] = 'Taxonomic assignment publication registered' - when 'emend' - pn.update(emends: true) - flash[:notice] = 'Emending publication registered' - else - flash[:notice] = 'Publication registered' - end - redirect_to(@name) + case params[:link_name][:as] + when 'propose' + @name.update(proposed_in: @publication) + flash[:notice] = 'Effective publication registered' + when 'not_valid_proposal' + pn.update(not_valid_proposal: true) + flash[:notice] = + 'Original (not valid) publication registered' + when 'corrig' + redirect_to( + corrigendum_in_name_url(@name, publication_id: @publication.id) + ) + return + when 'assign' + @name.update(assigned_in: @publication) + flash[:notice] = 'Taxonomic assignment publication registered' + when 'emend' + pn.update(emends: true) + flash[:notice] = 'Emending publication registered' else - redirect_to(@publication) + flash[:notice] = 'Publication registered' end + redirect_to(@name) + else + redirect_to(@publication) end end @@ -128,6 +141,15 @@ def set_publication params[:doi] ? Publication.find_by(doi: params[:doi]) : nil end + # Fields a curator may hand-enter to register a publication without a + # DOI (e.g. an older paper never issued one). Deliberately excludes + # crossref_json/datacite_json, which only ever hold verbatim external-API + # payloads and have no place in a manually-entered record. + def publication_manual_params + params.require(:publication) + .permit(*%i[title journal journal_loc journal_date doi url pub_type abstract]) + end + # Never trust parameters from the scary internet, only allow the white list through. def publication_params params.require(:publication) diff --git a/app/models/name_paratype.rb b/app/models/name_paratype.rb index 7fa19003..4c0e61c8 100644 --- a/app/models/name_paratype.rb +++ b/app/models/name_paratype.rb @@ -10,4 +10,17 @@ class NameParatype < ApplicationRecord # Per the SeqCode paratype amendment (DOI:10.1093/ismeco/ycaf238), only # isolated strains can serve as paratypes validates(:nomenclatural_type_type, inclusion: { in: %w[Strain] }) + + after_create(:link_publication_to_name) + + private + + # A paratype's registering publication should also be recognized as one + # of the name's own publications, same as propose/assign/emend/corrig + # already ensure (see PublicationsController#create). Uses find_or_create + # so this holds regardless of which code path creates the NameParatype, + # and is a no-op if the publication is already linked. + def link_publication_to_name + PublicationName.find_or_create_by(publication: publication, name: name) + end end diff --git a/app/models/publication.rb b/app/models/publication.rb index 5615730a..5ab374dc 100644 --- a/app/models/publication.rb +++ b/app/models/publication.rb @@ -3,6 +3,16 @@ class Publication < ApplicationRecord default_scope { order(journal_date: :desc) } + # An empty string isn't the same as NULL for the DB's unique index on doi + # (two blank-string dois would collide; two NULLs don't), so normalize + # blank input before it ever reaches a validation or a save. + before_validation { self.doi = nil if doi.blank? } + + validates(:title, presence: true) + validates(:journal_date, presence: true) + validates(:pub_type, presence: true) + validates(:doi, uniqueness: true, allow_nil: true) + has_many( :publication_authors, -> { order('publication_authors.id ASC') }, dependent: :destroy @@ -34,8 +44,35 @@ class Publication < ApplicationRecord class << self def by_autocomplete(query) - doi = query&.sub(/: .*/, '') - doi.present? ? by_doi(doi) : nil + key = query&.sub(/: .*/, '') + return nil unless key.present? + + key.start_with?('id:') ? + by_id(key.delete_prefix('id:')) : + by_doi(key) + end + + def by_id(id) + Publication.find_by(id: id) || + Publication.new.tap { |i| i.errors.add(:doi, 'publication not found') } + end + + # Creates a Publication without going through DOI lookup (e.g. a + # curator-entered legacy paper that never received one), with its + # authors, atomically: if either the publication itself is invalid or + # an author fails to save, nothing is persisted — avoids ever leaving + # behind an author-less publication because add_authors partially + # failed after the publication itself saved fine. + def create_without_doi(params, authors: []) + p = new(params) + transaction do + if p.save + p.add_authors(authors) + else + raise ActiveRecord::Rollback + end + end + p end def by_doi(doi, force_update = false) @@ -200,6 +237,39 @@ def journals where.not(journal: ['', nil]).select(:journal).reorder(:journal).distinct end + # Curated subset of the Crossref/DataCite `type` vocabulary offered in + # the manual-entry dropdown. Values match what by_serrano_work/ + # by_datacite_work already store, so existing display code (e.g. + # #citation, #journal_html) keeps working unchanged either way. + def pub_types + { + 'journal-article' => 'Journal article', + 'book' => 'Book', + 'book-chapter' => 'Book chapter', + 'proceedings-article' => 'Conference proceedings', + 'report' => 'Report', + 'other' => 'Other' + } + end + + end + + ## + # Attach authors given as an array of [given, family] pairs, in + # authorship order. Mirrors the author-creation loop in + # +by_uniform_hash_work+, so the same dedup/standardization rules + # (Author.find_or_create) apply regardless of whether the author list + # came from Crossref/DataCite or was hand-entered. + def add_authors(pairs) + pairs.each_with_index do |(given, family), i| + next if family.blank? + author = Author.find_or_create(given, family) + next if authors.include?(author) + PublicationAuthor.create!( + publication_id: id, author_id: author.id, + sequence: i.zero? ? 'first' : 'additional' + ) + end end def authors_et_al(format = :text) @@ -248,17 +318,20 @@ def journal_raw def long_citation(format = :text) case format.to_sym when :html + doi_html = doi.present? ? + %(DOI: #{doi}) : '' <<~HTML.html_safe - #{authors_et_al(format)} (#{journal_date.year}). #{title_html}. + #{authors_et_al(format)} (#{journal_date.year}). #{title_html}. #{journal_html}. - DOI: #{doi} + #{doi_html} HTML when :wikispecies "#{authors_array(format).join(', ')}. #{journal_date.year}: " \ - "#{title.gsub(/[\n\r\s]+/, ' ')}. #{journal_raw}. {{Doi|#{doi}}}" + "#{title.gsub(/[\n\r\s]+/, ' ')}. #{journal_raw}" \ + "#{doi.present? ? ". {{Doi|#{doi}}}" : '.'}" else "#{authors_et_al(format)} (#{journal_date.year}). " \ - "#{title}. #{journal_raw}. DOI: #{doi}" + "#{title}. #{journal_raw}#{doi.present? ? ". DOI: #{doi}" : '.'}" end end @@ -283,11 +356,12 @@ def prepub? end def link - "https://doi.org/#{doi}" + "https://doi.org/#{doi}" if doi.present? end def doi_title(html = true) - html ? "#{doi}: #{title_html}" : "#{doi}: #{title}" + key = doi.presence || "id:#{id}" + html ? "#{key}: #{title_html}" : "#{key}: #{title}" end def include_term?(term) diff --git a/app/models/register/status.rb b/app/models/register/status.rb index 9f425406..28ac2640 100644 --- a/app/models/register/status.rb +++ b/app/models/register/status.rb @@ -124,9 +124,10 @@ def endorse(user) # # user: The user notifying the Registry (the current user) # params: The notification parameters - # doi: DOI of the effective publication + # doi: DOI of the effective publication, or a doi_title-style "key: title" + # round-trip string identifying a DOI-less publication def notify(user, params, doi) - publication = Publication.by_doi(doi) + publication = Publication.by_autocomplete(doi) || Publication.by_doi(doi) params[:publication] = publication if publication.new_record? diff --git a/app/views/contacts/new.html.erb b/app/views/contacts/new.html.erb index 002838c5..0fb674ce 100644 --- a/app/views/contacts/new.html.erb +++ b/app/views/contacts/new.html.erb @@ -25,10 +25,13 @@
<%= link_to(@publication) do %> <%= fa_icon('arrow-circle-right') %> <%= @publication.citation %> - <% end %> / - <%= link_to(@publication.link, target: '_blank') do %> - DOI: <%= @publication.doi %> - <%= fa_icon('external-link-alt') %> + <% end %> + <% if @publication.doi.present? %> + / + <%= link_to(@publication.link, target: '_blank') do %> + DOI: <%= @publication.doi %> + <%= fa_icon('external-link-alt') %> + <% end %> <% end %>
diff --git a/app/views/contacts/show.html.erb b/app/views/contacts/show.html.erb index 9b5be610..1f4eda72 100644 --- a/app/views/contacts/show.html.erb +++ b/app/views/contacts/show.html.erb @@ -9,10 +9,13 @@
<%= link_to(@publication) do %> <%= fa_icon('arrow-circle-right') %> <%= @publication.citation %> - <% end %>
- <%= link_to(@publication.link, target: '_blank') do %> - DOI: <%= @publication.doi %> - <%= fa_icon('external-link-alt') %> + <% end %> + <% if @publication.doi.present? %> +
+ <%= link_to(@publication.link, target: '_blank') do %> + DOI: <%= @publication.doi %> + <%= fa_icon('external-link-alt') %> + <% end %> <% end %>
diff --git a/app/views/publications/_form.html.erb b/app/views/publications/_form.html.erb index e1c5f0c7..48b560d5 100644 --- a/app/views/publications/_form.html.erb +++ b/app/views/publications/_form.html.erb @@ -28,7 +28,10 @@
<%= form.label :journal_date %> - <%= form.date_select :journal_date, id: :publication_journal_date %> + <%= form.date_select( + :journal_date, { start_year: 1900, end_year: Date.current.year + 1 }, + id: :publication_journal_date + ) %>
@@ -43,7 +46,20 @@
<%= form.label :pub_type %> - <%= form.text_field :pub_type, id: :publication_pub_type %> + <% + pub_type_options = Publication.pub_types.to_a.map(&:reverse) + if publication.pub_type.present? && + !Publication.pub_types.key?(publication.pub_type) + # Preserve an existing value outside the curated list (e.g. a + # Crossref/DataCite type like "dataset" or "posted-content") as + # its own option, instead of silently losing it on the next save. + pub_type_options << [publication.pub_type, publication.pub_type] + end + %> + <%= form.select( + :pub_type, pub_type_options, { include_blank: true }, + id: :publication_pub_type + ) %>
diff --git a/app/views/publications/new.html.erb b/app/views/publications/new.html.erb index e247ff8e..bfb1c7bd 100644 --- a/app/views/publications/new.html.erb +++ b/app/views/publications/new.html.erb @@ -1,14 +1,124 @@

New Publication

- Only publications with assigned Digital Object Identifier - (<%= link_to('DOI', 'https://doi.org/', target: '_blank') %>) - can be registered + <% if current_curator? %> + Publications are normally registered by their Digital Object Identifier + (<%= link_to('DOI', 'https://doi.org/', target: '_blank') %>). As a + curator, you may leave the DOI blank and enter the publication's + details manually instead (e.g. for older papers that never received one). + <% else %> + Only publications with assigned Digital Object Identifier + (<%= link_to('DOI', 'https://doi.org/', target: '_blank') %>) + can be registered + <% end %>

<%= simple_form_for(@publication, hrml: { method: :post }) do |f| %> <%= f.input(:doi, - required: true, autofocus: true, label: 'DOI of publication') %> + required: !current_curator?, autofocus: true, + label: 'DOI of publication', + hint: 'Enter a DOI to search or register a publication, ' \ + 'or search by title to find an existing one', + input_html: { + data: { behavior: 'autocomplete', autocomplete: 'publications' } + }) %> + <% if current_curator? %> + <% + # Mirrors PublicationsController#create's own branch condition, so a + # re-render after a failed manual submission (even one that failed + # specifically because title/journal_date/etc. were left blank) still + # expands the section instead of hiding the curator's own entry + # behind a collapsed toggle. + manual_entry_active = + params[:publication].present? && params[:publication][:doi].blank? + %> +

+ <%= link_to('Register a publication without a DOI', '#', + id: 'toggle-manual-entry', + class: 'btn btn-outline-secondary btn-sm') %> +

+
+ Publication details +

+ Leave DOI blank above and fill in the publication's details manually: +

+ <%= f.input(:title, required: true) %> + <%= f.input(:journal, + hint: 'Use the full journal name, not an abbreviation') %> + <%= f.input(:journal_loc, label: 'Journal location (volume/issue)') %> + <%= f.input(:journal_date, required: true, + start_year: 1900, end_year: Date.current.year + 1, + hint: 'If the exact date is unknown, use January 1st') %> + <%= f.input(:pub_type, + collection: Publication.pub_types.to_a.map(&:reverse), + include_blank: 'Select a publication type', + required: true, label: 'Publication type') %> + <%= f.input(:url) %> + <%= f.input(:abstract, as: :text) %> + + <% + # Repopulate from the previous submission if this is a re-render + # after a validation error, so authors survive it the same way + # every other field already does via @publication's own attributes. + author_pairs = + (params[:authors_given] || []).zip(params[:authors_family] || []) + author_pairs = [[nil, nil]] if author_pairs.empty? + %> +
+ +
+ <% author_pairs.each do |given, family| %> +
+
+ <%= text_field_tag( + 'authors_given[]', given, class: 'form-control', + placeholder: 'Given name (e.g. initials)' + ) %> +
+
+ <%= text_field_tag( + 'authors_family[]', family, class: 'form-control', + placeholder: 'Family name' + ) %> +
+
+ +
+
+ <% end %> +
+ +
+
+ + + <% end %> <% if @name %> <%= hidden_field(:link_name, :id, value: @name.id) %>

diff --git a/app/views/publications/show.html.erb b/app/views/publications/show.html.erb index 2bee92b9..4e82df6c 100644 --- a/app/views/publications/show.html.erb +++ b/app/views/publications/show.html.erb @@ -24,13 +24,21 @@

<%= fa_icon('calendar-day') %> Publication date
<%= @publication.journal_date %>
-
<%= fa_icon('directions') %> DOI
-
- <%= link_to(@publication.url, target: '_blank') do %> - <%= @publication.doi %>  - <%= fa_icon('external-link-alt') %> - <% end %> -
+ <% if @publication.doi.present? %> +
<%= fa_icon('directions') %> DOI
+
+ <%= link_to(@publication.url, target: '_blank') do %> + <%= @publication.doi %>  + <%= fa_icon('external-link-alt') %> + <% end %> +
+ <% end %> + <% if @publication.url.present? %> +
<%= fa_icon('link') %> URL
+
+ <%= link_to(@publication.url, @publication.url, target: '_blank') %> +
+ <% end %> <%= render(partial: 'curator') %>
diff --git a/app/views/registers/cite.xml.erb b/app/views/registers/cite.xml.erb index bdf3a71e..4a4b88c6 100644 --- a/app/views/registers/cite.xml.erb +++ b/app/views/registers/cite.xml.erb @@ -83,6 +83,7 @@ <%# ========= Citations ========= %> <% @register.citations.each do |publication| %> + <% next unless publication.doi.present? %> <%= publication.doi %> <% end %> diff --git a/app/views/registers/notify.html.erb b/app/views/registers/notify.html.erb index dbada0ba..d5343d63 100644 --- a/app/views/registers/notify.html.erb +++ b/app/views/registers/notify.html.erb @@ -37,7 +37,10 @@

Effective Publication

<%= f.input( :doi, as: :fake, label: 'DOI', required: true, - input_html: { value: @register.publication.try(:doi) }, + input_html: { + value: @register.publication.try(:doi) || + @register.publication.try(:doi_title, false) + }, hint: 'Digital Object Identifier (DOI) of the effective publication' ) %> <% unless @publications.empty? %> @@ -45,11 +48,11 @@ <% @publications.each do |publication| %>
  • <%= link_to(publication.short_citation, publication) %>: - <%= publication.doi %> + <%= publication.doi || 'no DOI' %> <%= content_tag( 'span', role: 'button', cursor: 'pointer', class: 'badge badge-pill badge-primary', - onclick: "$('#doi').val('#{publication.doi}'); false" + onclick: "$('#doi').val('#{publication.doi || publication.doi_title(false)}'); false" ) do %> <%= fa_icon('check-circle') %> Select <% end %> diff --git a/config/routes.rb b/config/routes.rb index ab2cb581..08b135b1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -209,7 +209,16 @@ resources(:authors, concerns: :autocompletable) # Journals - resources(:journals, only: %i[index show], param: :journal) + # Journal names routinely contain periods (e.g. "Cryptogamie. Algologie"), + # which Rails' default segment matcher excludes to leave room for format + # detection (":journal.json"), so it silently truncates them instead. + # Constrain :journal to swallow everything but a real trailing ".json"/ + # ".html", so a literal period in the name is matched as part of :journal + # while an actual format suffix is still recognized. + resources( + :journals, only: %i[index show], param: :journal, + constraints: { journal: /[^\/]+?/, format: /json|html/ } + ) # Publications resources(:publications, concerns: :autocompletable) do diff --git a/test/controllers/journals_controller_test.rb b/test/controllers/journals_controller_test.rb new file mode 100644 index 00000000..bbf5f6c3 --- /dev/null +++ b/test/controllers/journals_controller_test.rb @@ -0,0 +1,29 @@ +require 'test_helper' + +class JournalsControllerTest < ActionDispatch::IntegrationTest + test 'show resolves a journal name with an embedded period, untruncated' do + get('/journals/' + ERB::Util.url_encode('Cryptogamie. Algologie')) + assert_response :success + assert_equal 'Cryptogamie. Algologie', @request.params[:journal] + assert_nil @request.params[:format] + end + + test 'show resolves a journal name with a trailing period' do + get('/journals/' + ERB::Util.url_encode('Can. J. Fish. Aquat. Sci. Bull.')) + assert_response :success + assert_equal 'Can. J. Fish. Aquat. Sci. Bull.', @request.params[:journal] + end + + test 'show still honors an explicit .json format on a plain journal name' do + get('/journals/' + ERB::Util.url_encode('Nature') + '.json') + assert_response :success + assert_equal 'Nature', @request.params[:journal] + assert_equal 'json', @request.params[:format] + end + + test 'index still honors an explicit .json format' do + get('/journals.json') + assert_response :success + assert_equal 'json', @request.params[:format] + end +end diff --git a/test/controllers/publications_controller_test.rb b/test/controllers/publications_controller_test.rb index ea66ebbf..dd9c247d 100644 --- a/test/controllers/publications_controller_test.rb +++ b/test/controllers/publications_controller_test.rb @@ -1,8 +1,169 @@ require 'test_helper' class PublicationsControllerTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + setup do @publication = publications(:one) end + test 'curator sees the manual-entry form with an 1900-based year range' do + sign_in(users(:curator)) + + get(new_publication_url) + + assert_response :success + assert_includes @response.body, 'Authors (given name, family name)' + assert_select( + 'select#publication_journal_date_1i option[value="1900"]' + ) + end + + test 'manual-entry section is collapsed by default' do + sign_in(users(:curator)) + + get(new_publication_url) + + assert_response :success + assert_select('#manual-entry-section.d-none') + end + + test 'manual-entry section re-expands after a failed manual submission' do + sign_in(users(:curator)) + + post( + publications_url, + params: { + publication: { + doi: '', title: '', journal: 'Journal of Examples', + journal_date: Date.new(1978, 6, 1), pub_type: 'journal-article' + } + } + ) + + assert_response :success + assert_select('#manual-entry-section:not(.d-none)') + end + + test 'curator can register a publication without a doi' do + sign_in(users(:curator)) + + assert_difference('Publication.count', 1) do + post( + publications_url, + params: { + publication: { + doi: '', title: 'A Manually Entered Paper', + journal: 'Journal of Examples', + journal_date: Date.new(1978, 6, 1), pub_type: 'journal-article' + } + } + ) + end + + publication = Publication.find_by(title: 'A Manually Entered Paper') + assert_not_nil publication + assert_nil publication.doi + assert_redirected_to publication + end + + test 'curator can register a publication with manually entered authors' do + sign_in(users(:curator)) + + post( + publications_url, + params: { + publication: { + doi: '', title: 'A Multi-Author Paper', + journal: 'Journal of Examples', + journal_date: Date.new(1978, 6, 1), pub_type: 'journal-article' + }, + authors_given: ['J.', 'A.'], + authors_family: ['Doe', 'Smith'] + } + ) + + publication = Publication.find_by(title: 'A Multi-Author Paper') + assert_not_nil publication + assert_equal ['Doe', 'Smith'], publication.authors.pluck(:family) + end + + test 'authors survive a re-render after a validation error' do + sign_in(users(:curator)) + + assert_no_difference('Publication.count') do + post( + publications_url, + params: { + publication: { + doi: '', title: '', journal: 'Journal of Examples', + journal_date: Date.new(1978, 6, 1), pub_type: 'journal-article' + }, + authors_given: ['J.', 'A.'], + authors_family: ['Doe', 'Smith'] + } + ) + end + + assert_response :success + assert_select('input[name="authors_given[]"][value="J."]') + assert_select('input[name="authors_family[]"][value="Doe"]') + assert_select('input[name="authors_given[]"][value="A."]') + assert_select('input[name="authors_family[]"][value="Smith"]') + end + + test 'curator can link an existing doi-less publication to a name' do + sign_in(users(:curator)) + name = names(:bacillus_subtilis) + publication = publications(:no_doi) + + assert_no_difference('Publication.count') do + post( + publications_url, + params: { + publication: { doi: publication.doi_title(false) }, + link_name: { id: name.id, as: 'cite' } + } + ) + end + + assert_includes(name.reload.publications, publication) + assert_redirected_to(name) + end + + test 'non-curator can also link an existing doi-less publication to a name' do + sign_in(users(:contributor)) + name = names(:bacillus_subtilis) + publication = publications(:no_doi) + + post( + publications_url, + params: { + publication: { doi: publication.doi_title(false) }, + link_name: { id: name.id, as: 'cite' } + } + ) + + assert_includes(name.reload.publications, publication) + end + + test 'non-curator cannot register a publication without a doi' do + sign_in(users(:contributor)) + + assert_no_difference('Publication.count') do + post( + publications_url, + params: { + publication: { + doi: '', title: 'A Manually Entered Paper', + journal: 'Journal of Examples', + journal_date: Date.new(1978, 6, 1), pub_type: 'journal-article' + } + } + ) + end + + assert_response :success + assert_includes @response.body, 'New Publication' + end end diff --git a/test/fixtures/publications.yml b/test/fixtures/publications.yml index 6c3a3345..278e717c 100644 --- a/test/fixtures/publications.yml +++ b/test/fixtures/publications.yml @@ -8,6 +8,7 @@ one: doi: MyString url: MyString crossref_json: MyString + pub_type: journal-article two: title: MyString @@ -17,3 +18,13 @@ two: doi: MyOtherString url: MyString crossref_json: MyString + pub_type: journal-article + +no_doi: + title: A Classic Pre-Digital Paper + journal: MyString + journal_loc: MyString + journal_date: 2019-04-11 + doi: + url: + pub_type: journal-article diff --git a/test/models/name_paratype_test.rb b/test/models/name_paratype_test.rb index 4825d255..c9be9984 100644 --- a/test/models/name_paratype_test.rb +++ b/test/models/name_paratype_test.rb @@ -38,6 +38,30 @@ class NameParatypeTest < ActiveSupport::TestCase assert(name_paratype.valid?) end + test 'links the paratype publication to the name if not already linked' do + name = names(:bacillus_subtilis) + publication = publications(:one) + assert_not_includes(name.publications, publication) + + NameParatype.create!( + name: name, nomenclatural_type: strains(:one), publication: publication + ) + + assert_includes(name.reload.publications, publication) + end + + test 'does not duplicate an already-existing publication link' do + name = names(:bacillus_subtilis) + publication = publications(:one) + PublicationName.create!(publication: publication, name: name) + + assert_no_difference('PublicationName.count') do + NameParatype.create!( + name: name, nomenclatural_type: strains(:one), publication: publication + ) + end + end + test 'only allows strains as paratypes' do name_paratype = NameParatype.new( name: names(:bacillus_subtilis), nomenclatural_type: genomes(:one), diff --git a/test/models/publication_test.rb b/test/models/publication_test.rb index 12defd6c..d074b65e 100644 --- a/test/models/publication_test.rb +++ b/test/models/publication_test.rb @@ -1,7 +1,156 @@ require 'test_helper' +require 'minitest/mock' class PublicationTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test 'requires a title' do + p = Publication.new( + journal_date: Date.new(2020, 1, 1), pub_type: 'journal-article' + ) + assert_not p.valid? + assert_includes p.errors[:title], "can't be blank" + end + + test 'requires a journal_date' do + p = Publication.new(title: 'A Paper', pub_type: 'journal-article') + assert_not p.valid? + assert_includes p.errors[:journal_date], "can't be blank" + end + + test 'requires a pub_type' do + p = Publication.new(title: 'A Paper', journal_date: Date.new(2020, 1, 1)) + assert_not p.valid? + assert_includes p.errors[:pub_type], "can't be blank" + end + + test 'allows multiple publications without a doi' do + a = Publication.new( + title: 'A Paper', journal_date: Date.new(2020, 1, 1), + pub_type: 'journal-article' + ) + b = Publication.new( + title: 'Another Paper', journal_date: Date.new(2021, 1, 1), + pub_type: 'journal-article' + ) + assert a.valid? + assert b.valid? + end + + test 'rejects a duplicate doi' do + dup = Publication.new( + title: 'Duplicate', journal_date: Date.new(2020, 1, 1), + pub_type: 'journal-article', doi: publications(:one).doi + ) + assert_not dup.valid? + assert_includes dup.errors[:doi], 'has already been taken' + end + + test 'link is nil without a doi' do + assert_nil publications(:no_doi).link + assert_equal( + 'https://doi.org/%s' % publications(:one).doi, publications(:one).link + ) + end + + test 'doi_title falls back to an id-based key without a doi' do + p = publications(:no_doi) + assert_equal("id:#{p.id}: #{p.title}", p.doi_title(false)) + end + + test 'by_autocomplete resolves the id-based key back to the publication' do + p = publications(:no_doi) + found = Publication.by_autocomplete(p.doi_title(false)) + assert_equal p, found + end + + test 'by_autocomplete still resolves a plain doi' do + p = publications(:one) + found = Publication.by_autocomplete(p.doi_title(false)) + assert_equal p, found + end + + test 'long_citation omits the DOI clause when doi is blank' do + p = publications(:no_doi) + assert_not_includes p.long_citation(:text), 'DOI' + assert_not_includes p.long_citation(:html), 'DOI' + assert_not_includes p.long_citation(:wikispecies), 'Doi' + end + + test 'long_citation includes the DOI clause when doi is present' do + p = publications(:one) + assert_includes p.long_citation(:text), "DOI: #{p.doi}" + assert_includes p.long_citation(:html), "DOI: #{p.doi}" + assert_includes p.long_citation(:wikispecies), "{{Doi|#{p.doi}}}" + end + + test 'add_authors creates authors in order with a first/additional sequence' do + p = publications(:no_doi) + p.add_authors([['J.', 'Doe'], ['A.', 'Smith']]) + + assert_equal ['Doe', 'Smith'], p.reload.authors.pluck(:family) + sequences = p.publication_authors.order(:id).pluck(:sequence) + assert_equal %w[first additional], sequences + end + + test 'add_authors skips rows with a blank family name' do + p = publications(:no_doi) + assert_no_difference('Author.count') do + p.add_authors([['', ''], [nil, '']]) + end + assert_empty p.reload.authors + end + + test 'add_authors does not duplicate an already-linked author' do + p = publications(:no_doi) + p.add_authors([['J.', 'Doe']]) + assert_no_difference('PublicationAuthor.count') do + p.add_authors([['J.', 'Doe']]) + end + end + + test 'pub_types is used for the manual-entry dropdown' do + assert_includes Publication.pub_types.keys, 'journal-article' + assert_equal 'Journal article', Publication.pub_types['journal-article'] + end + + test 'create_without_doi persists the publication and its authors together' do + p = Publication.create_without_doi( + { + title: 'A Manually Entered Paper', journal_date: Date.new(1978, 6, 1), + pub_type: 'journal-article' + }, + authors: [['J.', 'Doe'], ['A.', 'Smith']] + ) + + assert p.persisted? + assert_equal ['Doe', 'Smith'], p.authors.pluck(:family) + end + + test 'create_without_doi returns an unsaved record with errors when invalid' do + assert_no_difference('Publication.count') do + p = Publication.create_without_doi( + { title: '', journal_date: Date.new(1978, 6, 1), + pub_type: 'journal-article' }, + authors: [['J.', 'Doe']] + ) + + assert_not p.persisted? + assert_includes p.errors[:title], "can't be blank" + end + end + + test 'create_without_doi rolls back the publication if author creation fails' do + Author.stub( + :find_or_create, ->(*) { raise ActiveRecord::RecordInvalid, Author.new } + ) do + assert_no_difference('Publication.count') do + assert_raises(ActiveRecord::RecordInvalid) do + Publication.create_without_doi( + { title: 'A Doomed Paper', journal_date: Date.new(1978, 6, 1), + pub_type: 'journal-article' }, + authors: [['J.', 'Doe']] + ) + end + end + end + end end