From fb387aa3c098dcf430e7b0e9b4657af7447c0bc7 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Tue, 6 Oct 2026 04:54:16 -0700 Subject: [PATCH] Test virtual host mapping, sitemaps and timeblock preferences Tests for Intercode::VirtualHostConstraint and FindVirtualHost (host, proxy header and override precedence, assets, debug logging), the sitemap controller (pages, root page, hidden pages, events, dropped events, root site) and EventProposal::TimeblockPreference (time parsing, descriptions, equality with hashes and parameters). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013mDr1nhgyzST2iPzCzMcCd --- test/controllers/sitemaps_controller_test.rb | 123 ++++++++++++++++ test/lib/intercode/virtual_host_test.rb | 131 ++++++++++++++++++ .../timeblock_preference_test.rb | 110 +++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 test/controllers/sitemaps_controller_test.rb create mode 100644 test/lib/intercode/virtual_host_test.rb create mode 100644 test/models/event_proposal/timeblock_preference_test.rb diff --git a/test/controllers/sitemaps_controller_test.rb b/test/controllers/sitemaps_controller_test.rb new file mode 100644 index 0000000000..2084c02ec9 --- /dev/null +++ b/test/controllers/sitemaps_controller_test.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true +require "test_helper" + +describe SitemapsController do + let(:convention) { create(:convention) } + + def sitemap_urls + xml = Nokogiri.XML(response.body) + xml.remove_namespaces! + xml.xpath("//url").map { |url| [url.at_xpath("loc").text, url.at_xpath("lastmod")&.text] } + end + + def locations + sitemap_urls.map(&:first) + end + + describe "GET show for a convention" do + setup { set_convention convention } + + it "is a sitemap in XML" do + get :show + + assert_response :success + assert_equal "application/xml", response.media_type + assert_equal "urlset", Nokogiri.XML(response.body).root.name + assert_equal "http://www.sitemaps.org/schemas/sitemap/0.9", Nokogiri.XML(response.body).root.namespace.href + end + + it "is empty for a convention with no pages and no events" do + get :show + + assert_empty locations + end + + it "lists pages with when they were last changed" do + page = create(:page, parent: convention, slug: "about") + + get :show + + assert_equal [["http://#{convention.domain}/pages/about", page.updated_at.xmlschema]], sitemap_urls + end + + it "lists the root page at the root of the site" do + page = create(:page, parent: convention, slug: "welcome") + convention.update!(root_page: page) + + get :show + + assert_equal ["http://#{convention.domain}/"], locations + end + + it "leaves out pages hidden from search" do + create(:page, parent: convention, slug: "visible") + create(:page, parent: convention, slug: "secret", hidden_from_search: true) + + get :show + + assert_equal ["http://#{convention.domain}/pages/visible"], locations + end + + it "lists the events pages, the schedule and each active event once there are events" do + event = create(:event, convention:, title: "Dragon Hunt") + create(:run, event:) + + get :show + + assert_includes locations, "http://#{convention.domain}/events" + assert_includes locations, "http://#{convention.domain}/events/schedule" + assert_includes locations, "http://#{convention.domain}/events/#{event.to_param}" + end + + it "says the events lists were last changed when the latest event or run was" do + event = create(:event, convention:) + run = create(:run, event:) + run.update_columns(updated_at: 1.day.from_now) # rubocop:disable Rails/SkipsModelValidations + + get :show + + events_entry = sitemap_urls.find { |location, _lastmod| location.end_with?("/events") } + assert_equal run.reload.updated_at.xmlschema, events_entry.last + end + + it "leaves out dropped events" do + create(:event, convention:, title: "Kept") + dropped = create(:event, convention:, title: "Dropped", status: "dropped") + + get :show + + assert_not_includes locations, "http://#{convention.domain}/events/#{dropped.to_param}" + end + + it "leaves out the event pages when every event has been dropped" do + create(:event, convention:, status: "dropped") + + get :show + + assert_not_includes locations, "http://#{convention.domain}/events" + end + + it "does not include another convention's pages" do + create(:page, parent: create(:convention), slug: "elsewhere") + + get :show + + assert_empty locations + end + end + + describe "GET show for the root site" do + setup { @request.host = "root.example.com" } + + it "lists the root site's pages and none of the convention things" do + root_site = create(:root_site) + root_site.pages.create!(name: "About us", slug: "about-us", content: "Hello") + + get :show + + assert_response :success + assert_includes locations, "http://root.example.com/pages/about-us" + assert_not(locations.any? { |location| location.include?("/events") }) + end + end +end diff --git a/test/lib/intercode/virtual_host_test.rb b/test/lib/intercode/virtual_host_test.rb new file mode 100644 index 0000000000..a5af18f702 --- /dev/null +++ b/test/lib/intercode/virtual_host_test.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true +require "test_helper" + +class Intercode::VirtualHostTest < ActiveSupport::TestCase + describe Intercode::VirtualHostConstraint do + let(:constraint) { Intercode::VirtualHostConstraint.new } + + it "matches requests that were mapped to a convention" do + convention = create(:convention) + request = ActionDispatch::Request.new("intercode.convention" => convention) + + assert constraint.matches?(request) + end + + it "does not match requests for the root site" do + assert_not constraint.matches?(ActionDispatch::Request.new({})) + assert_not constraint.matches?(ActionDispatch::Request.new("intercode.convention" => nil)) + end + end + + describe ".with_virtual_host_domain" do + it "overrides the domain inside the block, and gives it back afterwards" do + assert_nil Intercode.overridden_virtual_host_domain + + Intercode.with_virtual_host_domain("example.com") do + assert_equal "example.com", Intercode.overridden_virtual_host_domain + end + + assert_nil Intercode.overridden_virtual_host_domain + end + + it "gives it back even if the block raises" do + assert_raises(RuntimeError) { Intercode.with_virtual_host_domain("example.com") { raise "boom" } } + + assert_nil Intercode.overridden_virtual_host_domain + end + + it "returns what the block returns" do + assert_equal 42, Intercode.with_virtual_host_domain("example.com") { 42 } + end + end + + describe Intercode::FindVirtualHost do + let(:downstream) { ->(env) { [200, {}, [env["intercode.convention"]&.name.to_s]] } } + let(:middleware) { Intercode::FindVirtualHost.new(downstream) } + let(:convention) { create(:convention, name: "Mapped Con", domain: "mapped.example.com") } + + def request_env(host: "mapped.example.com", path: "/", **extra) + Rack::MockRequest.env_for("http://#{host}#{path}", extra) + end + + def found_convention(env) + middleware.call(env) + env["intercode.convention"] + end + + it "maps a request to the convention with its domain" do + convention + + assert_equal convention, found_convention(request_env) + end + + it "maps a request for an unknown domain to no convention, meaning the root site" do + convention + + assert_nil found_convention(request_env(host: "unknown.example.com")) + end + + it "passes the request on to the app" do + convention + + assert_equal [200, {}, ["Mapped Con"]], middleware.call(request_env) + end + + it "prefers a convention domain header from a front-end proxy to the host" do + convention + + env = request_env(:host => "proxy.internal", "HTTP_X_INTERCODE_CONVENTION_DOMAIN" => "mapped.example.com") + + assert_equal convention, found_convention(env) + end + + it "prefers an overridden domain to both" do + convention + other = create(:convention, domain: "other.example.com") + + Intercode.with_virtual_host_domain("other.example.com") do + env = request_env(:host => "mapped.example.com", "HTTP_X_INTERCODE_CONVENTION_DOMAIN" => "mapped.example.com") + + assert_equal other, found_convention(env) + end + end + + it "leaves a convention that an earlier layer already chose alone" do + convention + chosen = create(:convention, domain: "chosen.example.com") + + env = request_env("intercode.convention" => chosen) + + assert_equal chosen, found_convention(env) + end + + it "does not look up a convention for asset requests" do + convention + env = request_env(path: "#{Rails.application.config.assets.prefix}/application.js") + + middleware.call(env) + + assert_not env.key?("intercode.convention") + end + + it "says how it mapped the request when debugging is turned on" do + convention + messages = [] + original = ENV.fetch("FIND_VIRTUAL_HOST_DEBUG", nil) + ENV["FIND_VIRTUAL_HOST_DEBUG"] = "1" + + Rails + .logger + .stub(:info, ->(message) { messages << message }) do + middleware.call(request_env) + middleware.call(request_env(host: "unknown.example.com")) + end + + assert_match(/mapped.example.com mapped\s+to\s+Mapped Con/m, messages.first) + assert_match(/unknown.example.com mapped to root site/, messages.second) + ensure + ENV["FIND_VIRTUAL_HOST_DEBUG"] = original + end + end +end diff --git a/test/models/event_proposal/timeblock_preference_test.rb b/test/models/event_proposal/timeblock_preference_test.rb new file mode 100644 index 0000000000..d851029201 --- /dev/null +++ b/test/models/event_proposal/timeblock_preference_test.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true +require "test_helper" + +class EventProposal::TimeblockPreferenceTest < ActiveSupport::TestCase + let(:start) { Time.utc(2026, 6, 5, 18, 0, 0) } + let(:finish) { Time.utc(2026, 6, 5, 22, 0, 0) } + + def preference(**attrs) + EventProposal::TimeblockPreference.new({ start:, finish:, label: "Evening", ordinality: "1" }.merge(attrs)) + end + + describe "times" do + it "keeps times as they are" do + assert_equal start, preference.start + assert_equal finish, preference.finish + end + + it "reads ISO 8601 strings" do + parsed = preference(start: "2026-06-05T18:00:00Z", finish: "2026-06-05T22:00:00-04:00") + + assert_equal start, parsed.start + assert_equal Time.utc(2026, 6, 6, 2, 0, 0), parsed.finish + end + + it "raises for a string that is not an ISO 8601 time" do + assert_raises(ArgumentError) { preference(start: "next tuesday") } + end + + it "can be changed afterwards" do + pref = preference + pref.start = "2026-06-06T10:00:00Z" + pref.finish = Time.utc(2026, 6, 6, 12) + + assert_equal Time.utc(2026, 6, 6, 10), pref.start + assert_equal Time.utc(2026, 6, 6, 12), pref.finish + end + end + + describe "#attributes and serialization" do + it "has the four attributes" do + assert_equal({ start:, finish:, label: "Evening", ordinality: "1" }, preference.attributes) + end + + it "serializes to JSON" do + json = preference.as_json + + assert_equal "Evening", json["label"] + assert_equal "1", json["ordinality"] + assert_equal start.as_json, json["start"] + end + end + + describe "#ordinality_description" do + { + "1" => "1st Choice", + "2" => "2nd Choice", + "3" => "3rd Choice", + "X" => "Not Available" + }.each do |ordinality, description| + it "describes #{ordinality} as #{description}" do + assert_equal description, preference(ordinality:).ordinality_description + end + end + + it "understands ordinalities given as numbers" do + assert_equal "2nd Choice", preference(ordinality: 2).ordinality_description + end + + it "has no description for anything else" do + assert_nil preference(ordinality: "4").ordinality_description + assert_nil preference(ordinality: nil).ordinality_description + end + end + + describe "#==" do + it "is equal to another preference with the same attributes" do + assert_equal preference, preference + end + + it "is not equal when any attribute differs" do + assert_not_equal preference, preference(label: "Other") + assert_not_equal preference, preference(ordinality: "2") + assert_not_equal preference, preference(start: start + 1.hour) + assert_not_equal preference, preference(finish: finish + 1.hour) + end + + it "is equal to a hash of its attributes, with string or symbol keys" do + assert_equal preference, { start:, finish:, label: "Evening", ordinality: "1" } + assert_equal preference, + { "start" => "2026-06-05T18:00:00Z", "finish" => finish, "label" => "Evening", "ordinality" => "1" } + end + + it "is equal to controller parameters with its attributes" do + params = + ActionController::Parameters.new( + start: "2026-06-05T18:00:00Z", + finish: "2026-06-05T22:00:00Z", + label: "Evening", + ordinality: "1" + ) + + assert_equal preference, params + end + + it "is not equal to something else" do + assert_not_equal preference, "Evening" + assert_not_equal preference, nil + end + end +end