diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7eb8f3..3a3774b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: runs-on: ubuntu-latest env: TEST_DATABASE_URL: mysql2://root:mysql@127.0.0.1:3306/intercode_import_test + EVENTLITE_TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/eventlite_test services: mysql: image: mysql:8 @@ -30,6 +31,18 @@ jobs: --health-retries 5 ports: - 3306:3306 + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: eventlite_test + options: >- + --health-cmd "pg_isready" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 steps: - uses: actions/checkout@v6 - name: Set up Ruby diff --git a/Gemfile b/Gemfile index cf9bf7e..76c15db 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ ruby File.read(File.expand_path('.ruby-version', __dir__)).strip gem 'activesupport' gem 'bcrypt' gem 'mysql2', '~> 0.5.3' +gem 'pg' gem 'nokogiri' gem 'parallel' gem 'rake' diff --git a/Gemfile.lock b/Gemfile.lock index ad73c51..7fda5b7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -53,6 +53,13 @@ GEM nokogiri (1.19.3-x86_64-linux-musl) racc (~> 1.4) parallel (2.1.0) + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-darwin) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) prism (1.9.0) racc (1.8.1) rake (13.4.2) @@ -84,6 +91,7 @@ DEPENDENCIES mysql2 (~> 0.5.3) nokogiri parallel + pg rake reverse_markdown sequel @@ -113,6 +121,13 @@ CHECKSUMS nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976 nokogiri (1.19.3-x86_64-linux-musl) sha256=248c906d2166eca5efb56d52fdee5f9a1f51d69a72e2b64fdac647b4ce39ea3f parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 + pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea + pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c + pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f + pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d + pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 diff --git a/README.md b/README.md index c9d6238..847262a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Ruby toolkit for exporting legacy convention management system data into the [Intercode](https://github.com/neinteractiveliterature/intercode) convention import format. -Three source systems are supported: **Intercode 1** (PHP/MySQL), **ProCon** (MySQL), and **Illyan** (the user authentication database used alongside ProCon). +Four source systems are supported: **Intercode 1** (PHP/MySQL), **ProCon** (MySQL), **Illyan** (the user authentication database used alongside ProCon), and **Eventlite** (Rails/PostgreSQL). ## Output format @@ -66,6 +66,27 @@ ORGANIZATION_NAME='Arisia' \ | `ORGANIZATION_NAME` | Yes | Organization name written into each export file. | | `OUTPUT_FILE` | No | Output path. Only used when exactly one convention matches; otherwise files are named `convention-export-.json`. Pass `-` to print to stdout. | +### Eventlite + +Exports one JSON file per event from an Eventlite PostgreSQL database. Each event becomes a separate single-event Intercode convention. + +```sh +EVENTLITE_DB_URL=postgres://user:pass@host/eventlite_db \ +DOMAIN_SUFFIX=example.com \ +TIMEZONE=America/New_York \ + bundle exec rake export:eventlite +``` + +| Variable | Required | Description | +|---|---|---| +| `EVENTLITE_DB_URL` | Yes | Sequel-compatible connection URL for the Eventlite PostgreSQL database. | +| `DOMAIN_SUFFIX` | No | Domain suffix appended to each event's slug to form the convention domain (default: `example.com`). | +| `TIMEZONE` | No | IANA timezone name applied to all conventions (default: `UTC`). | +| `FILE_BASE_URL` | No | Base URL for CMS file attachments stored in S3 (e.g. `https://my-bucket.s3.amazonaws.com/`). | +| `OUTPUT_FILE` | No | Output path. Only used when exactly one event is found; otherwise files are named `convention-export-.json`. Pass `-` to print to stdout. | + +Each Eventlite event is exported as a `single_event` Intercode convention using the `ticket_per_event` ticket mode. Ticket types become Intercode ticket types and store items; users who purchased multiple ticket types get one ticket (the type with the most available slots wins) and additional store order entries for the rest. Pages containing Eventlite-specific Liquid tags (e.g. `{% ticket_form %}`) are omitted, as is any navigation pointing to those pages. + ### Illyan (standalone user export) Exports a set of users from the Illyan database by email address. Useful for migrating user accounts without a full convention export. diff --git a/convention-export.schema.json b/convention-export.schema.json index 3e9add4..142f7bf 100644 --- a/convention-export.schema.json +++ b/convention-export.schema.json @@ -8,7 +8,7 @@ "additionalProperties": false, "properties": { "version": { "type": "string", "const": "1" }, - "source_system": { "type": "string", "enum": ["intercode1", "procon", "other"] }, + "source_system": { "type": "string", "enum": ["intercode1", "procon", "eventlite", "other"] }, "organization_name": { "type": ["string", "null"] }, "cms_content_set": { "type": ["string", "null"], @@ -28,7 +28,8 @@ "cms_files": { "type": "array", "items": { "$ref": "#/$defs/cms_file" } }, "cms_pages": { "type": "array", "items": { "$ref": "#/$defs/cms_page" } }, "cms_partials": { "type": "array", "items": { "$ref": "#/$defs/cms_partial" } }, - "cms_navigation_items": { "type": "array", "items": { "$ref": "#/$defs/cms_navigation_section" } } + "cms_navigation_items": { "type": "array", "items": { "$ref": "#/$defs/cms_navigation_section" } }, + "cms_layouts": { "type": "array", "items": { "$ref": "#/$defs/cms_layout" } } }, "$defs": { @@ -296,7 +297,11 @@ "name": { "type": "string" }, "description": { "type": ["string", "null"] }, "available": { "type": "boolean" }, - "price": { "$ref": "#/$defs/money" } + "price": { "$ref": "#/$defs/money" }, + "provides_ticket_type_name": { + "type": ["string", "null"], + "description": "Name of the ticket_type this product provides when purchased" + } } }, @@ -379,7 +384,18 @@ "properties": { "name": { "type": "string" }, "slug": { "type": "string" }, - "content": { "type": "string", "description": "Liquid template content" } + "content": { "type": "string", "description": "Liquid template content" }, + "cms_layout_name": { "type": ["string", "null"], "description": "Name of the cms_layout to use for this page" } + } + }, + + "cms_layout": { + "type": "object", + "required": ["name", "content"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "content": { "type": "string", "description": "Liquid template for the layout" } } }, diff --git a/lib/intercode_import/eventlite.rb b/lib/intercode_import/eventlite.rb new file mode 100644 index 0000000..3512d1e --- /dev/null +++ b/lib/intercode_import/eventlite.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative 'eventlite/table' +require_relative 'eventlite/tables' +require_relative 'eventlite/exporter' + +module IntercodeImport + module Eventlite + def self.logger + IntercodeImport::Logger.instance + end + end +end diff --git a/lib/intercode_import/eventlite/exporter.rb b/lib/intercode_import/eventlite/exporter.rb new file mode 100644 index 0000000..f166d18 --- /dev/null +++ b/lib/intercode_import/eventlite/exporter.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +require 'sequel' +require 'set' + +module IntercodeImport + module Eventlite + class Exporter + def initialize(db_url, domain_suffix: 'example.com', timezone: 'UTC', file_base_url: nil) + logger.info 'Connecting to Eventlite database' + @connection = Sequel.connect(db_url) + @domain_suffix = domain_suffix + @timezone = timezone + @file_base_url = file_base_url + end + + def export + site_settings = @connection[:site_settings].first + + users_table = Tables::Users.new(@connection) + all_users = users_table.export! + user_email_by_id = users_table.id_map + + @connection[:events].order(:id).map do |event_row| + export_event(event_row, site_settings, all_users, user_email_by_id) + end + end + + private + + def export_event(event_row, site_settings, all_users, user_email_by_id) + event_id = event_row[:id] + event_name = event_row[:name] + event_slug = event_row[:slug].presence || event_name.to_s.downcase.gsub(/\s+/, '-') + + layouts_table = Tables::CmsLayouts.new(@connection, event_id) + cms_layouts = layouts_table.export! + layout_name_by_id = layouts_table.id_map + + default_layout_name = layout_name_by_id[event_row[:default_cms_layout_id]] + + root_page_slug = nil + if event_row[:root_page_id] + root_page_row = @connection[:pages].where(id: event_row[:root_page_id]).first + root_page_slug = root_page_row&.fetch(:slug, nil) + end + + ticket_type_rows = @connection[:ticket_types].where(event_id: event_id).order(:id).all + ticket_type_name_by_id = ticket_type_rows.each_with_object({}) { |r, h| h[r[:id]] = r[:name] } + ticket_type_slug_by_id = ticket_type_rows.each_with_object({}) { |r, h| h[r[:id]] = slug_for(r[:name]) } + + ticket_types = ticket_type_rows.map do |tt| + { + name: slug_for(tt[:name]), + description: tt[:name], + allows_event_signups: true, + counts_towards_convention_maximum: true + } + end + + tickets_table = Tables::Tickets.new(@connection, event_id, ticket_type_slug_by_id, user_email_by_id) + tickets = deduplicate_tickets(tickets_table.export!, ticket_type_rows) + + ticket_emails = Set.new(tickets.map { |t| t[:user_email] }) + event_users = all_users.select { |u| ticket_emails.include?(u[:email]) } + user_con_profiles = build_profiles(event_id, user_email_by_id) + + store_items = build_store_items(ticket_type_rows) + store_orders = build_store_orders(event_id, ticket_type_name_by_id, user_email_by_id, ticket_type_rows) + + cms_pages = Tables::Pages.new(@connection, event_id, layout_name_by_id).export! + cms_files = Tables::CmsFiles.new(@connection, event_id, @file_base_url).export! + cms_nav_items = Tables::NavigationItems.new(@connection, event_id).export! + + convention = { + name: event_name, + domain: "#{event_slug}.#{@domain_suffix}", + timezone_name: @timezone, + site_mode: 'single_event', + ticket_mode: 'ticket_per_event', + ticket_types: ticket_types, + event_categories: [default_event_category], + rooms: [], + staff_positions: admin_staff_positions(event_users) + } + + if event_row[:start_time] + convention[:starts_at] = event_row[:start_time].iso8601 + if event_row[:length_seconds] + convention[:ends_at] = (event_row[:start_time] + event_row[:length_seconds]).iso8601 + end + end + + convention[:default_layout_name] = default_layout_name if default_layout_name + convention[:root_page_slug] = root_page_slug if root_page_slug + + event_record = { + id: event_id.to_s, + title: event_name, + event_category_name: 'Event', + status: 'active', + registration_policy: build_registration_policy(ticket_type_rows) + } + event_record[:length_seconds] = event_row[:length_seconds] if event_row[:length_seconds] + + run = { event_id: event_id.to_s, room_names: [] } + run[:starts_at] = event_row[:start_time].iso8601 if event_row[:start_time] + + signups = build_signups(event_id.to_s, tickets, ticket_type_rows) + + { + version: '1', + source_system: 'eventlite', + cms_content_set: 'single_event', + convention: convention, + users: event_users, + user_con_profiles: user_con_profiles, + events: [event_record], + runs: [run], + signups: signups, + team_members: [], + tickets: tickets, + store_items: store_items, + store_orders: store_orders, + cms_layouts: cms_layouts, + cms_pages: cms_pages, + cms_files: cms_files, + cms_navigation_items: cms_nav_items + } + end + + def build_registration_policy(ticket_type_rows) + if ticket_type_rows.size <= 1 + return { + buckets: [{ key: 'attendees', name: 'Attendees', slots_limited: false, anything: false }], + prevent_no_preference_signups: false + } + end + + buckets = ticket_type_rows.map do |tt| + bucket = { key: slug_for(tt[:name]), name: tt[:name], anything: false } + if tt[:number_available] + bucket.merge!(slots_limited: true, total_slots: tt[:number_available], + minimum_slots: 0, preferred_slots: tt[:number_available]) + else + bucket[:slots_limited] = false + end + bucket + end + { buckets: buckets, prevent_no_preference_signups: false } + end + + def build_signups(event_id_str, tickets, ticket_type_rows) + if ticket_type_rows.size <= 1 + return tickets.map do |ticket| + { event_id: event_id_str, run_index: 0, user_email: ticket[:user_email], + state: 'confirmed', bucket_key: 'attendees', counted: true } + end + end + + # tickets already carry slug-form ticket_type_name; key by slug for slot lookup + slots_by_slug = ticket_type_rows.each_with_object({}) { |r, h| h[slug_for(r[:name])] = r[:number_available] || 0 } + + tickets.group_by { |t| t[:user_email] }.map do |user_email, user_tickets| + best = user_tickets.max_by { |t| slots_by_slug[t[:ticket_type_name]] || 0 } + { event_id: event_id_str, run_index: 0, user_email: user_email, + state: 'confirmed', bucket_key: best[:ticket_type_name], counted: true } + end + end + + def slug_for(name) + name.to_s.downcase.gsub(/[-\s]+/, '_').gsub(/[^\w]/, '') + end + + def deduplicate_tickets(tickets, ticket_type_rows) + return tickets if ticket_type_rows.size <= 1 + slots_by_slug = ticket_type_rows.each_with_object({}) { |r, h| h[slug_for(r[:name])] = r[:number_available] || 0 } + tickets.group_by { |t| t[:user_email] }.map do |_email, user_tickets| + user_tickets.max_by { |t| slots_by_slug[t[:ticket_type_name]] || 0 } + end + end + + def build_store_items(ticket_type_rows) + ticket_type_rows.map do |tt| + item = { name: tt[:name], available: true } + item[:price] = { fractional: tt[:price_cents], currency_code: 'USD' } if tt[:price_cents] + item + end + end + + def build_store_orders(event_id, ticket_type_name_by_id, user_email_by_id, ticket_type_rows) + price_by_ticket_type_id = ticket_type_rows.each_with_object({}) { |r, h| h[r[:id]] = r[:price_cents] } + + ticket_rows = @connection[:tickets] + .join(:ticket_types, id: :ticket_type_id) + .where(Sequel[:ticket_types][:event_id] => event_id) + .select_all(:tickets) + .all + + ticket_rows.filter_map do |row| + user_email = + if row[:user_id] + user_email_by_id[row[:user_id]] + else + row[:email].to_s.downcase.strip.presence + end + next unless user_email + + ticket_type_name = ticket_type_name_by_id[row[:ticket_type_id]] + next unless ticket_type_name + + entry = { store_item_name: ticket_type_name, quantity: 1 } + + list_price_cents = price_by_ticket_type_id[row[:ticket_type_id]] + entry[:price_per_item] = { fractional: list_price_cents, currency_code: 'USD' } if list_price_cents + + order = { + user_email: user_email, + status: row[:canceled_at] ? 'cancelled' : 'paid', + entries: [entry] + } + + if row[:payment_amount_cents] + order[:payment_amount] = { fractional: row[:payment_amount_cents], currency_code: 'USD' } + end + + order + end + end + + def build_profiles(event_id, user_email_by_id) + ticket_rows = @connection[:tickets] + .join(:ticket_types, id: :ticket_type_id) + .where(Sequel[:ticket_types][:event_id] => event_id) + .where(Sequel[:tickets][:canceled_at] => nil) + .select_all(:tickets) + .order(Sequel[:tickets][:id]) + .all + + seen = {} + ticket_rows.each_with_object([]) do |row, profiles| + user_email = + if row[:user_id] + user_email_by_id[row[:user_id]] + else + row[:email].to_s.downcase.strip.presence + end + next unless user_email + next if seen[user_email] + + seen[user_email] = true + first_name, last_name = split_name(row[:name].to_s.strip) + + profile = { user_email: user_email } + profile[:first_name] = first_name if first_name.present? + profile[:last_name] = last_name if last_name.present? + profile[:phone] = row[:phone].presence + + profiles << profile.compact + end + end + + def admin_staff_positions(event_users) + admin_emails = @connection[:users] + .where(admin: true) + .select(:email) + .map { |r| r[:email].to_s.downcase.strip } + .select { |e| e.present? && event_users.any? { |u| u[:email] == e } } + + return [] if admin_emails.empty? + + [{ + name: 'Admin', + visible: false, + permissions: ['update_convention', 'update_cms_content', 'update_event_proposals', + 'read_event_proposals', 'update_events', 'update_rooms', 'update_schedule'], + user_emails: admin_emails + }] + end + + def default_event_category + { + name: 'Event', + team_member_name: 'Organizer', + scheduling_ui: 'single_run', + event_form_title: 'Regular event form' + } + end + + def split_name(full_name) + return ['', ''] if full_name.blank? + parts = full_name.split(' ', 2) + [parts[0] || '', parts[1] || ''] + end + + def logger + Eventlite.logger + end + end + end +end diff --git a/lib/intercode_import/eventlite/table.rb b/lib/intercode_import/eventlite/table.rb new file mode 100644 index 0000000..3709a9d --- /dev/null +++ b/lib/intercode_import/eventlite/table.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + class Table < IntercodeImport::Table + # Liquid tags that exist in Eventlite but not in Intercode's environment. + EVENTLITE_ONLY_TAGS = %w[ticket_form].freeze + + def table_name + self.class.name.demodulize.underscore.to_sym + end + + private + + def row_id(row) = row[:id] + + def logger + IntercodeImport::Eventlite.logger + end + + def eventlite_only_content?(content) + return false unless content&.include?('{%') + EVENTLITE_ONLY_TAGS.any? { |tag| content.match?(/\{%-?\s*#{tag}[\s%}]/) } + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables.rb b/lib/intercode_import/eventlite/tables.rb new file mode 100644 index 0000000..3bd2e3c --- /dev/null +++ b/lib/intercode_import/eventlite/tables.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require_relative 'tables/users' +require_relative 'tables/tickets' +require_relative 'tables/cms_layouts' +require_relative 'tables/pages' +require_relative 'tables/cms_files' +require_relative 'tables/navigation_items' diff --git a/lib/intercode_import/eventlite/tables/cms_files.rb b/lib/intercode_import/eventlite/tables/cms_files.rb new file mode 100644 index 0000000..c61f89e --- /dev/null +++ b/lib/intercode_import/eventlite/tables/cms_files.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'net/http' +require 'base64' +require 'uri' + +module IntercodeImport + module Eventlite + module Tables + class CmsFiles < Eventlite::Table + def initialize(connection, event_id, file_base_url) + super(connection) + @event_id = event_id + @file_base_url = file_base_url + end + + def dataset + connection[:cms_files].where( + Sequel.lit('(parent_type = ? AND parent_id = ?) OR parent_type IS NULL', 'Event', @event_id) + ) + end + + def export! + unless @file_base_url + logger.warn 'FILE_BASE_URL not set; skipping CMS file export' + return [] + end + + logger.info "Exporting CmsFiles for event #{@event_id}" + results = [] + + dataset.each do |row| + filename = row[:file].to_s + next if filename.blank? + + # CarrierWave store_dir: uploads/cms_file/file/{id}/{filename} + file_path = "uploads/cms_file/file/#{row[:id]}/#{filename}" + url = @file_base_url.chomp('/') + '/' + file_path + + content, content_type = fetch_file(url) + next unless content + + results << { + filename: filename, + content_base64: Base64.strict_encode64(content), + content_type: content_type || 'application/octet-stream' + } + end + + results + end + + private + + def fetch_file(url) + uri = URI.parse(url) + response = Net::HTTP.get_response(uri) + + unless response.is_a?(Net::HTTPSuccess) + logger.warn "Failed to fetch #{url}: #{response.code} #{response.message}" + return nil + end + + content_type = response['Content-Type']&.split(';')&.first&.strip + [response.body, content_type] + rescue StandardError => e + logger.warn "Error fetching #{url}: #{e.message}" + nil + end + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables/cms_layouts.rb b/lib/intercode_import/eventlite/tables/cms_layouts.rb new file mode 100644 index 0000000..a90d239 --- /dev/null +++ b/lib/intercode_import/eventlite/tables/cms_layouts.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + module Tables + class CmsLayouts < Eventlite::Table + def initialize(connection, event_id) + super(connection) + @event_id = event_id + end + + def dataset + connection[:cms_layouts].where( + Sequel.lit('(parent_type = ? AND parent_id = ?) OR parent_type IS NULL', 'Event', @event_id) + ) + end + + def export! + logger.info "Exporting CmsLayouts for event #{@event_id}" + results = [] + + dataset.each do |row| + id_map[row[:id]] = row[:name] + results << { name: row[:name], content: row[:content] || '' } + end + + results + end + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables/navigation_items.rb b/lib/intercode_import/eventlite/tables/navigation_items.rb new file mode 100644 index 0000000..bf35a50 --- /dev/null +++ b/lib/intercode_import/eventlite/tables/navigation_items.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + module Tables + class NavigationItems < Eventlite::Table + def initialize(connection, event_id) + super(connection) + @event_id = event_id + end + + def dataset + connection[:navigation_items].where( + Sequel.lit('(parent_type = ? AND parent_id = ?) OR parent_type IS NULL', 'Event', @event_id) + ).order(:position) + end + + def export! + logger.info "Exporting NavigationItems for event #{@event_id}" + + page_name_by_id = connection[:pages].select(:id, :name, :content).all.each_with_object({}) do |row, h| + next if eventlite_only_content?(row[:content]) + h[row[:id]] = row[:name] + end + + all_items = dataset.all + top_level = all_items.select { |r| r[:navigation_section_id].nil? } + children_by_section = all_items.group_by { |r| r[:navigation_section_id] } + children_by_section.delete(nil) + + sections = [] + top_level.each do |item| + if item[:page_id].nil? + links = (children_by_section[item[:id]] || []).filter_map do |child| + page_name = page_name_by_id[child[:page_id]] + next unless page_name + { title: child[:title], page_name: page_name } + end + sections << { title: item[:title], links: links } unless links.empty? + else + page_name = page_name_by_id[item[:page_id]] + next unless page_name + sections << { title: item[:title], links: [{ title: item[:title], page_name: page_name }] } + end + end + + sections + end + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables/pages.rb b/lib/intercode_import/eventlite/tables/pages.rb new file mode 100644 index 0000000..eb1e0e4 --- /dev/null +++ b/lib/intercode_import/eventlite/tables/pages.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + module Tables + class Pages < Eventlite::Table + def initialize(connection, event_id, layout_name_by_id) + super(connection) + @event_id = event_id + @layout_name_by_id = layout_name_by_id + end + + def dataset + connection[:pages].where( + Sequel.lit('(parent_type = ? AND parent_id = ?) OR parent_type IS NULL', 'Event', @event_id) + ) + end + + def export! + logger.info "Exporting Pages for event #{@event_id}" + results = [] + + dataset.each do |row| + content = row[:content] || '' + + if eventlite_only_content?(content) + logger.info "Skipping page '#{row[:slug]}' (contains Eventlite-only tag)" + next + end + + record = { name: row[:name], slug: row[:slug], content: content } + layout_name = @layout_name_by_id[row[:cms_layout_id]] + record[:cms_layout_name] = layout_name if layout_name + results << record + end + + results + end + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables/tickets.rb b/lib/intercode_import/eventlite/tables/tickets.rb new file mode 100644 index 0000000..7e29c78 --- /dev/null +++ b/lib/intercode_import/eventlite/tables/tickets.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + module Tables + class Tickets < Eventlite::Table + def initialize(connection, event_id, ticket_type_name_by_id, user_email_by_id) + super(connection) + @event_id = event_id + @ticket_type_name_by_id = ticket_type_name_by_id + @user_email_by_id = user_email_by_id + end + + def dataset + connection[:tickets] + .join(:ticket_types, id: :ticket_type_id) + .where(Sequel[:ticket_types][:event_id] => @event_id) + .where(Sequel[:tickets][:canceled_at] => nil) + .select_all(:tickets) + end + + def export! + logger.info "Exporting Tickets for event #{@event_id}" + results = [] + + dataset.each do |row| + user_email = + if row[:user_id] + @user_email_by_id[row[:user_id]] + else + row[:email].to_s.downcase.strip.presence + end + next unless user_email + + ticket_type_name = @ticket_type_name_by_id[row[:ticket_type_id]] + next unless ticket_type_name + + results << { user_email: user_email, ticket_type_name: ticket_type_name } + end + + results + end + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables/users.rb b/lib/intercode_import/eventlite/tables/users.rb new file mode 100644 index 0000000..7cbdf9b --- /dev/null +++ b/lib/intercode_import/eventlite/tables/users.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + module Tables + class Users < Eventlite::Table + def initialize(connection) + super + @names_by_user_id = load_names_from_tickets + end + + def export! + logger.info 'Exporting Users' + results = [] + seen_emails = {} + + dataset.each do |row| + email = row[:email].to_s.downcase.strip + next if email.blank? + next if seen_emails[email] + + seen_emails[email] = true + first_name, last_name = parse_name(@names_by_user_id[row[:id]]) + first_name = email_prefix(email) if first_name.blank? && last_name.blank? + + record = { + email: email, + first_name: first_name, + last_name: last_name + } + + if row[:encrypted_password].present? + record[:password_hash] = row[:encrypted_password] + record[:password_hash_type] = 'bcrypt' + end + + id_map[row[:id]] = email + results << record + end + + ticket_only_rows.each do |row| + email = row[:email].to_s.downcase.strip + next if email.blank? || seen_emails[email] + + seen_emails[email] = true + first_name, last_name = parse_name(row[:name]) + first_name = email_prefix(email) if first_name.blank? && last_name.blank? + results << { email: email, first_name: first_name, last_name: last_name } + end + + results + end + + private + + def load_names_from_tickets + connection[:tickets].select(:user_id, :name).all.each_with_object({}) do |row, h| + next if h[row[:user_id]] || row[:name].to_s.blank? + h[row[:user_id]] = row[:name].to_s.strip + end + end + + def ticket_only_rows + connection[:tickets].where(user_id: nil).exclude(email: nil).select(:email, :name).all + end + + def parse_name(full_name) + return ['', ''] if full_name.blank? + parts = full_name.strip.split(' ', 2) + [parts[0] || '', parts[1] || ''] + end + + def email_prefix(email) + email.to_s.split('@').first.to_s + end + end + end + end +end diff --git a/tasks/export.rake b/tasks/export.rake index 8933907..d2f084d 100644 --- a/tasks/export.rake +++ b/tasks/export.rake @@ -5,6 +5,7 @@ require 'intercode_import' require 'intercode_import/intercode1' require 'intercode_import/procon' require 'intercode_import/illyan' +require 'intercode_import/eventlite' def fetch_env!(name) value = ENV[name].presence @@ -58,6 +59,29 @@ namespace :export do end end + desc 'Export Eventlite events to convention JSON format (one file per event)' + task :eventlite do + exporter = IntercodeImport::Eventlite::Exporter.new( + fetch_env!('EVENTLITE_DB_URL'), + domain_suffix: ENV['DOMAIN_SUFFIX'] || 'example.com', + timezone: ENV['TIMEZONE'] || 'UTC', + file_base_url: ENV['FILE_BASE_URL'] + ) + exports = exporter.export + exports.each do |data| + domain = data[:convention][:domain].tr('.', '-') + default_name = "convention-export-#{domain}.json" + output_file = exports.size == 1 ? (ENV['OUTPUT_FILE'] || default_name) : default_name + json = JSON.pretty_generate(data) + if output_file == '-' + puts json + else + File.write(output_file, json) + puts "Wrote #{output_file} (#{data[:convention][:name]})" + end + end + end + desc 'Export users from an Illyan database' task :illyan do emails = fetch_env!('EMAILS').strip.split(/\s+/) diff --git a/test/intercode_import/eventlite/cms_layouts_db_test.rb b/test/intercode_import/eventlite/cms_layouts_db_test.rb new file mode 100644 index 0000000..f1868e0 --- /dev/null +++ b/test/intercode_import/eventlite/cms_layouts_db_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'test_helper' + +class EventliteCmsLayoutsDbTest < Minitest::Test + include EventliteDbTestHelper + + def self.setup_db(db) + db.create_table!(:events) do + primary_key :id + String :name + end + db.create_table!(:cms_layouts) do + primary_key :id + String :name + String :content + Integer :parent_id + String :parent_type + end + end + + def self.teardown_db(db) + db.drop_table?(:cms_layouts) + db.drop_table?(:events) + end + + def truncate_tables(db) + db[:cms_layouts].delete + db[:events].delete + end + + def setup + super + @event_id = @db[:events].insert(name: 'Test Event') + end + + def export_layouts(event_id = @event_id) + IntercodeImport::Eventlite::Tables::CmsLayouts.new(@db, event_id).export! + end + + def test_event_scoped_layout_exported + @db[:cms_layouts].insert(name: 'Default', content: '{{ content_for_layout }}', + parent_type: 'Event', parent_id: @event_id) + layouts = export_layouts + assert_equal 1, layouts.size + assert_equal 'Default', layouts.first[:name] + assert_equal '{{ content_for_layout }}', layouts.first[:content] + end + + def test_global_layout_included + @db[:cms_layouts].insert(name: 'Global Layout', content: 'global', parent_type: nil, parent_id: nil) + assert_equal 1, export_layouts.size + end + + def test_other_event_layout_excluded + other_event_id = @db[:events].insert(name: 'Other Event') + @db[:cms_layouts].insert(name: 'Other Layout', content: 'other', + parent_type: 'Event', parent_id: other_event_id) + assert_empty export_layouts + end + + def test_id_map_keyed_by_layout_id + lid = @db[:cms_layouts].insert(name: 'My Layout', content: '', + parent_type: 'Event', parent_id: @event_id) + table = IntercodeImport::Eventlite::Tables::CmsLayouts.new(@db, @event_id) + table.export! + assert_equal 'My Layout', table.id_map[lid] + end +end diff --git a/test/intercode_import/eventlite/navigation_items_db_test.rb b/test/intercode_import/eventlite/navigation_items_db_test.rb new file mode 100644 index 0000000..f09b467 --- /dev/null +++ b/test/intercode_import/eventlite/navigation_items_db_test.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'test_helper' + +class EventliteNavigationItemsDbTest < Minitest::Test + include EventliteDbTestHelper + + def self.setup_db(db) + db.create_table!(:events) do + primary_key :id + String :name + end + db.create_table!(:pages) do + primary_key :id + String :name + String :slug + String :content + Integer :parent_id + String :parent_type + end + db.create_table!(:navigation_items) do + primary_key :id + String :title + Integer :page_id + Integer :navigation_section_id + Integer :parent_id + String :parent_type + Integer :position + end + end + + def self.teardown_db(db) + db.drop_table?(:navigation_items) + db.drop_table?(:pages) + db.drop_table?(:events) + end + + def truncate_tables(db) + db[:navigation_items].delete + db[:pages].delete + db[:events].delete + end + + def setup + super + @event_id = @db[:events].insert(name: 'Test Event') + @page_id = @db[:pages].insert(name: 'Home', slug: 'home', + parent_type: 'Event', parent_id: @event_id) + end + + def export_nav + IntercodeImport::Eventlite::Tables::NavigationItems.new(@db, @event_id).export! + end + + def test_returns_empty_when_no_items + assert_empty export_nav + end + + def test_standalone_link_produces_own_section + @db[:navigation_items].insert(title: 'Home', page_id: @page_id, position: 1, + parent_type: 'Event', parent_id: @event_id) + sections = export_nav + assert_equal 1, sections.size + assert_equal 'Home', sections.first[:title] + assert_equal [{ title: 'Home', page_name: 'Home' }], sections.first[:links] + end + + def test_section_header_with_children + section_id = @db[:navigation_items].insert(title: 'Info', page_id: nil, position: 1, + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Home', page_id: @page_id, position: 1, + navigation_section_id: section_id, + parent_type: 'Event', parent_id: @event_id) + sections = export_nav + assert_equal 1, sections.size + assert_equal 'Info', sections.first[:title] + assert_equal [{ title: 'Home', page_name: 'Home' }], sections.first[:links] + end + + def test_section_header_with_no_valid_children_excluded + section_id = @db[:navigation_items].insert(title: 'Info', page_id: nil, position: 1, + parent_type: 'Event', parent_id: @event_id) + reg_page_id = @db[:pages].insert(name: 'Registration', slug: 'registration', + content: '{% ticket_form %}', + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Register', page_id: reg_page_id, position: 1, + navigation_section_id: section_id, + parent_type: 'Event', parent_id: @event_id) + assert_empty export_nav + end + + def test_standalone_links_ordered_by_position + page2_id = @db[:pages].insert(name: 'About', slug: 'about', + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'About', page_id: page2_id, position: 2, + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Home', page_id: @page_id, position: 1, + parent_type: 'Event', parent_id: @event_id) + sections = export_nav + assert_equal ['Home', 'About'], sections.map { |s| s[:title] } + end + + def test_global_nav_items_included + global_page_id = @db[:pages].insert(name: 'Info', slug: 'info', parent_type: nil, parent_id: nil) + @db[:navigation_items].insert(title: 'Info', page_id: global_page_id, position: 1, + parent_type: nil, parent_id: nil) + assert_equal 1, export_nav.size + end + + def test_other_event_items_excluded + other_id = @db[:events].insert(name: 'Other') + other_page_id = @db[:pages].insert(name: 'Other Home', slug: 'other-home', + parent_type: 'Event', parent_id: other_id) + @db[:navigation_items].insert(title: 'Other Home', page_id: other_page_id, position: 1, + parent_type: 'Event', parent_id: other_id) + assert_empty export_nav + end + + def test_item_with_missing_page_skipped + @db[:navigation_items].insert(title: 'Broken', page_id: 99999, position: 1, + parent_type: 'Event', parent_id: @event_id) + assert_empty export_nav + end + + def test_nav_link_to_eventlite_only_page_skipped + reg_page_id = @db[:pages].insert(name: 'Registration', slug: 'registration', + content: '

Register

{% ticket_form %}', + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Register', page_id: reg_page_id, position: 1, + parent_type: 'Event', parent_id: @event_id) + assert_empty export_nav + end + + def test_nav_link_to_normal_page_kept_alongside_skipped_page + reg_page_id = @db[:pages].insert(name: 'Registration', slug: 'registration', + content: '{% ticket_form %}', + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Register', page_id: reg_page_id, position: 2, + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Home', page_id: @page_id, position: 1, + parent_type: 'Event', parent_id: @event_id) + sections = export_nav + assert_equal 1, sections.size + assert_equal 'Home', sections.first[:title] + assert_equal [{ title: 'Home', page_name: 'Home' }], sections.first[:links] + end +end diff --git a/test/intercode_import/eventlite/pages_db_test.rb b/test/intercode_import/eventlite/pages_db_test.rb new file mode 100644 index 0000000..e394dc8 --- /dev/null +++ b/test/intercode_import/eventlite/pages_db_test.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'test_helper' + +class EventlitePagesDbTest < Minitest::Test + include EventliteDbTestHelper + + def self.setup_db(db) + db.create_table!(:events) do + primary_key :id + String :name + end + db.create_table!(:cms_layouts) do + primary_key :id + String :name + String :content + Integer :parent_id + String :parent_type + end + db.create_table!(:pages) do + primary_key :id + String :name + String :slug + String :content + Integer :cms_layout_id + Integer :parent_id + String :parent_type + end + end + + def self.teardown_db(db) + db.drop_table?(:pages) + db.drop_table?(:cms_layouts) + db.drop_table?(:events) + end + + def truncate_tables(db) + db[:pages].delete + db[:cms_layouts].delete + db[:events].delete + end + + def setup + super + @event_id = @db[:events].insert(name: 'Test Event') + end + + def export_pages(layout_name_by_id = {}) + IntercodeImport::Eventlite::Tables::Pages.new(@db, @event_id, layout_name_by_id).export! + end + + def test_event_scoped_page_exported + @db[:pages].insert(name: 'Home', slug: 'home', content: 'Welcome!', + parent_type: 'Event', parent_id: @event_id) + pages = export_pages + assert_equal 1, pages.size + p = pages.first + assert_equal 'Home', p[:name] + assert_equal 'home', p[:slug] + assert_equal 'Welcome!', p[:content] + end + + def test_global_page_included + @db[:pages].insert(name: 'About', slug: 'about', content: 'About us', + parent_type: nil, parent_id: nil) + assert_equal 1, export_pages.size + end + + def test_other_event_page_excluded + other_id = @db[:events].insert(name: 'Other') + @db[:pages].insert(name: 'Other Page', slug: 'other', content: '', + parent_type: 'Event', parent_id: other_id) + assert_empty export_pages + end + + def test_cms_layout_name_included_when_mapped + lid = @db[:cms_layouts].insert(name: 'Default', content: '', + parent_type: 'Event', parent_id: @event_id) + @db[:pages].insert(name: 'Home', slug: 'home', content: '', + cms_layout_id: lid, parent_type: 'Event', parent_id: @event_id) + pages = export_pages(lid => 'Default') + assert_equal 'Default', pages.first[:cms_layout_name] + end + + def test_cms_layout_name_absent_when_not_mapped + @db[:pages].insert(name: 'Home', slug: 'home', content: '', + parent_type: 'Event', parent_id: @event_id) + pages = export_pages + refute pages.first.key?(:cms_layout_name) + end + + def test_nil_content_exported_as_empty_string + @db[:pages].insert(name: 'Empty', slug: 'empty', content: nil, + parent_type: 'Event', parent_id: @event_id) + assert_equal '', export_pages.first[:content] + end + + def test_page_with_ticket_form_tag_skipped + @db[:pages].insert(name: 'Registration', slug: 'registration', + content: '

Register

{% ticket_form %}', + parent_type: 'Event', parent_id: @event_id) + assert_empty export_pages + end + + def test_page_with_normal_liquid_tag_included + @db[:pages].insert(name: 'Home', slug: 'home', + content: '{% if user %}Hello{% endif %}', + parent_type: 'Event', parent_id: @event_id) + assert_equal 1, export_pages.size + end +end diff --git a/test/intercode_import/eventlite/tickets_db_test.rb b/test/intercode_import/eventlite/tickets_db_test.rb new file mode 100644 index 0000000..233bd73 --- /dev/null +++ b/test/intercode_import/eventlite/tickets_db_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'test_helper' + +class EventliteTicketsDbTest < Minitest::Test + include EventliteDbTestHelper + + def self.setup_db(db) + db.create_table!(:users) do + primary_key :id + String :email + end + db.create_table!(:events) do + primary_key :id + String :name + end + db.create_table!(:ticket_types) do + primary_key :id + Integer :event_id + String :name + end + db.create_table!(:tickets) do + primary_key :id + Integer :ticket_type_id + Integer :user_id + String :email + DateTime :canceled_at + end + end + + def self.teardown_db(db) + db.drop_table?(:tickets) + db.drop_table?(:ticket_types) + db.drop_table?(:events) + db.drop_table?(:users) + end + + def truncate_tables(db) + db[:tickets].delete + db[:ticket_types].delete + db[:events].delete + db[:users].delete + end + + def setup + super + @event_id = @db[:events].insert(name: 'Test Event') + @tt_id = @db[:ticket_types].insert(event_id: @event_id, name: 'General') + @user_id = @db[:users].insert(email: 'alice@example.com') + @ticket_type_names = { @tt_id => 'General' } + @user_emails = { @user_id => 'alice@example.com' } + end + + def export_tickets + IntercodeImport::Eventlite::Tables::Tickets.new( + @db, @event_id, @ticket_type_names, @user_emails + ).export! + end + + def test_basic_ticket_exported + @db[:tickets].insert(ticket_type_id: @tt_id, user_id: @user_id) + tickets = export_tickets + assert_equal 1, tickets.size + assert_equal 'alice@example.com', tickets.first[:user_email] + assert_equal 'General', tickets.first[:ticket_type_name] + end + + def test_canceled_ticket_excluded + @db[:tickets].insert(ticket_type_id: @tt_id, user_id: @user_id, canceled_at: Time.now) + assert_empty export_tickets + end + + def test_ticket_for_different_event_excluded + other_event_id = @db[:events].insert(name: 'Other Event') + other_tt_id = @db[:ticket_types].insert(event_id: other_event_id, name: 'VIP') + @db[:tickets].insert(ticket_type_id: other_tt_id, user_id: @user_id) + assert_empty export_tickets + end + + def test_unknown_user_id_skipped + @db[:tickets].insert(ticket_type_id: @tt_id, user_id: 9999) + assert_empty export_tickets + end + + def test_ticket_with_direct_email_exported + @db[:tickets].insert(ticket_type_id: @tt_id, user_id: nil, email: 'guest@example.com') + tickets = export_tickets + assert_equal 1, tickets.size + assert_equal 'guest@example.com', tickets.first[:user_email] + end + + def test_ticket_with_nil_user_id_and_nil_email_skipped + @db[:tickets].insert(ticket_type_id: @tt_id, user_id: nil, email: nil) + assert_empty export_tickets + end +end diff --git a/test/intercode_import/eventlite/users_db_test.rb b/test/intercode_import/eventlite/users_db_test.rb new file mode 100644 index 0000000..853a110 --- /dev/null +++ b/test/intercode_import/eventlite/users_db_test.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require 'test_helper' + +class EventliteUsersDbTest < Minitest::Test + include EventliteDbTestHelper + + def self.setup_db(db) + db.create_table!(:users) do + primary_key :id + String :email + String :encrypted_password + TrueClass :admin, default: false + end + db.create_table!(:events) do + primary_key :id + String :name + String :slug + end + db.create_table!(:ticket_types) do + primary_key :id + Integer :event_id + String :name + end + db.create_table!(:tickets) do + primary_key :id + Integer :ticket_type_id + Integer :user_id + String :name + String :email + String :phone + end + end + + def self.teardown_db(db) + db.drop_table?(:tickets) + db.drop_table?(:ticket_types) + db.drop_table?(:events) + db.drop_table?(:users) + end + + def truncate_tables(db) + db[:tickets].delete + db[:ticket_types].delete + db[:events].delete + db[:users].delete + end + + def insert_user(overrides = {}) + defaults = { email: 'alice@example.com', encrypted_password: '$2a$12$fakehash', admin: false } + @db[:users].insert(defaults.merge(overrides)) + end + + def insert_event(overrides = {}) + @db[:events].insert({ name: 'Test Event', slug: 'test-event' }.merge(overrides)) + end + + def insert_ticket_type(overrides = {}) + @db[:ticket_types].insert({ name: 'General' }.merge(overrides)) + end + + def insert_ticket(overrides = {}) + @db[:tickets].insert(overrides) + end + + def export_users + IntercodeImport::Eventlite::Tables::Users.new(@db).export! + end + + def test_basic_user_exported + insert_user(email: 'bob@example.com', encrypted_password: '$2a$12$somehash') + users = export_users + assert_equal 1, users.size + assert_equal 'bob@example.com', users.first[:email] + assert_equal '$2a$12$somehash', users.first[:password_hash] + assert_equal 'bcrypt', users.first[:password_hash_type] + end + + def test_blank_email_skipped + insert_user(email: '') + assert_empty export_users + end + + def test_email_normalised_to_lowercase + insert_user(email: 'BOB@EXAMPLE.COM') + assert_equal 'bob@example.com', export_users.first[:email] + end + + def test_duplicate_email_exported_once + insert_user(id: 1, email: 'dupe@example.com') + insert_user(id: 2, email: 'dupe@example.com') + assert_equal 1, export_users.size + end + + def test_name_inferred_from_ticket + uid = insert_user(email: 'jane@example.com') + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: uid, name: 'Jane Smith') + users = export_users + assert_equal 'Jane', users.first[:first_name] + assert_equal 'Smith', users.first[:last_name] + end + + def test_single_word_name_uses_empty_last_name + uid = insert_user(email: 'mono@example.com') + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: uid, name: 'Madonna') + users = export_users + assert_equal 'Madonna', users.first[:first_name] + assert_equal '', users.first[:last_name] + end + + def test_multi_word_last_name_preserved + uid = insert_user(email: 'multi@example.com') + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: uid, name: 'Jean-Luc Picard Smith') + users = export_users + assert_equal 'Jean-Luc', users.first[:first_name] + assert_equal 'Picard Smith', users.first[:last_name] + end + + def test_email_prefix_used_as_first_name_when_no_ticket + insert_user(email: 'notix@example.com') + users = export_users + assert_equal 'notix', users.first[:first_name] + assert_equal '', users.first[:last_name] + end + + def test_id_map_keyed_by_database_id + uid = insert_user(email: 'map@example.com') + table = IntercodeImport::Eventlite::Tables::Users.new(@db) + table.export! + assert_equal 'map@example.com', table.id_map[uid] + end + + def test_ticket_only_user_included + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: nil, email: 'guest@example.com', name: 'Guest User') + users = export_users + assert_equal 1, users.size + u = users.first + assert_equal 'guest@example.com', u[:email] + assert_equal 'Guest', u[:first_name] + assert_equal 'User', u[:last_name] + refute u.key?(:password_hash) + end + + def test_ticket_only_user_not_duplicated_when_user_account_exists + insert_user(email: 'overlap@example.com', encrypted_password: '$2a$12$hash') + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: nil, email: 'overlap@example.com', name: 'Overlap User') + users = export_users + assert_equal 1, users.size + assert users.first[:password_hash] + end + + def test_ticket_only_users_deduplicated_across_tickets + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: nil, email: 'dupe@example.com', name: 'Dupe One') + insert_ticket(ticket_type_id: ttid, user_id: nil, email: 'dupe@example.com', name: 'Dupe Two') + assert_equal 1, export_users.size + end +end diff --git a/test/support/eventlite_db_test_helper.rb b/test/support/eventlite_db_test_helper.rb new file mode 100644 index 0000000..4871bf8 --- /dev/null +++ b/test/support/eventlite_db_test_helper.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Mixin for Eventlite integration tests that need a live PostgreSQL connection. +# Set EVENTLITE_TEST_DATABASE_URL to a postgres:// URL to run these tests. +module EventliteDbTestHelper + UNSET = Object.new.freeze + private_constant :UNSET + + def self.included(base) + base.instance_variable_set(:@_db_conn, UNSET) + base.extend(ClassMethods) + end + + module ClassMethods + def db_connection + if @_db_conn.equal?(UNSET) + @_db_conn = + if ENV['EVENTLITE_TEST_DATABASE_URL'] + db = Sequel.connect(ENV['EVENTLITE_TEST_DATABASE_URL']) + setup_db(db) + Minitest.after_run { teardown_db(db); db.disconnect } + db + end + end + @_db_conn + end + + def setup_db(_db) = nil + def teardown_db(_db) = nil + end + + def setup + skip 'Set EVENTLITE_TEST_DATABASE_URL to run Eventlite DB integration tests' unless self.class.db_connection + @db = self.class.db_connection + truncate_tables(@db) + end + + def truncate_tables(_db) = nil +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 8f2e79e..c141f20 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -8,7 +8,9 @@ require 'intercode_import' require 'intercode_import/intercode1' require 'intercode_import/illyan' +require 'intercode_import/eventlite' require_relative 'support/db_test_helper' +require_relative 'support/eventlite_db_test_helper' if ENV['CI'].present? Minitest::Reporters.use!(