diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 64cd0a257d..fe3ec62aac 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -36,9 +36,12 @@ class Place::VisitorMailer < PlaceOS::Driver notify_original_host_template: "notify_original_host", # sent to all visitors when details change (date, time, location, etc.): # bookings (desk/resource) use booking_changed, calendar events (rooms) use event_changed - booking_changed_template: "booking_changed", - event_changed_template: "event_changed", - group_event_template: "group_event", + booking_changed_template: "booking_changed", + event_changed_template: "event_changed", + group_event_template: "group_event", + + # Combine duplicate change emails sent within this many seconds; 0 disables. + event_change_debounce: 15, disable_qr_code: false, send_network_credentials: false, network_password_length: DEFAULT_PASSWORD_LENGTH, @@ -157,6 +160,13 @@ class Place::VisitorMailer < PlaceOS::Driver @skip_event_linked_booking_email : Bool = true @skip_host_email : Bool = true + # Coalescing buffer for staff/event/changed, swept once the window elapses. + # seconds to buffer a change; 0 emails on every signal + @event_change_debounce : Int32 = 15 + # ical_uid (or event_id) => coalesced change awaiting its flush + @pending_event_changes : Hash(String, PendingEventChange) = {} of String => PendingEventChange + @pending_event_changes_lock : Mutex = Mutex.new + @uri : URI = URI.new @jwt_private_key : String = PlaceOS::Model::JWTBase.private_key @@ -176,6 +186,7 @@ class Place::VisitorMailer < PlaceOS::Driver @booking_changed_template = setting?(String, :booking_changed_template) || "booking_changed" @event_changed_template = setting?(String, :event_changed_template) || "event_changed" @group_event_template = setting?(String, :group_event_template) || "group_event" + @event_change_debounce = setting?(Int32, :event_change_debounce) || 15 @disable_qr_code = setting?(Bool, :disable_qr_code) || false @determine_host_name_using = setting?(String, :determine_host_name_using) || "calendar-driver" @send_network_credentials = setting?(Bool, :send_network_credentials) || false @@ -206,13 +217,27 @@ class Place::VisitorMailer < PlaceOS::Driver @zone_cache = ZoneCache.new zones = control_system_zone_list + + # The sweep below picks up the rest; with the debounce off nothing would. + flush_event_changes("debounce disabled") if @event_change_debounce <= 0 + schedule.clear if reminders = @send_reminders schedule.cron(reminders, @time_zone) { send_reminder_emails } end + + # Sweeps at most every 5s, so a change waits its debounce plus up to one interval. + schedule.every(@event_change_debounce.clamp(1, 5).seconds) { sweep_event_changes } if @event_change_debounce > 0 + spawn { ensure_building_zone(zones) } end + # The scheduler is dead by now, so nothing else would sweep the buffer. + # Bounded to return within the driver manager's 6s unload budget. + def on_unload + flush_event_changes("driver unloading", wait: 5.seconds) + end + def control_system_zone_list config.control_system.not_nil!.zones # ameba:disable Lint/NotNil end @@ -769,9 +794,105 @@ class Place::VisitorMailer < PlaceOS::Driver return unless fields_changed + # Coalesce the burst of signals Office365 emits per edit into one email. + change = PendingEventChange.new( + details.event_id, details.system_id, details.event_ical_uid, + host, details.title, event_start, event_end, + details.previous_event_start, details.previous_event_end, details.previous_system_id, + ) + @event_change_debounce > 0 ? buffer_event_change(change) : dispatch_event_change(change) + rescue error + logger.error { error.inspect_with_backtrace } + self[:error_count] = @error_count += 1 + self[:last_error] = { + error: error.message, + time: Time.local.to_s, + user: payload, + } + end + + # Collapses the burst of signals for one edit into a single buffered change. + # Keyed by event instance, so the rooms either side of a move coalesce too; + # the one email then names a single room and uses that room's guest list. + private def buffer_event_change(change : PendingEventChange) : Nil + @pending_event_changes_lock.synchronize do + if pending = @pending_event_changes[change.buffer_key]? + pending.merge(change) + else + @pending_event_changes[change.buffer_key] = change + end + end + end + + # Sends any change that has been buffered for its full debounce window. + private def sweep_event_changes : Nil + flush_event_changes("debounce window elapsed", older_than: Time.monotonic - @event_change_debounce.seconds) + end + + # Dispatches matching changes, each in its own fiber so a slow send can't stall + # the sweep. `older_than` limits the flush to entries buffered before that point + # (nil takes the lot), `wait` bounds how long we block for the sends to finish. + private def flush_event_changes(reason : String, older_than : Time::Span? = nil, wait : Time::Span? = nil) : Nil + flushing = @pending_event_changes_lock.synchronize do + ready = if cutoff = older_than + @pending_event_changes.values.select { |pending| pending.first_seen <= cutoff } + else + @pending_event_changes.values + end + ready.each { |pending| @pending_event_changes.delete(pending.buffer_key) } + ready + end + return if flushing.empty? + + logger.debug { "flushing #{flushing.size} pending event change(s): #{reason}" } + + complete = Channel(Nil).new(flushing.size) + flushing.each do |pending| + spawn do + dispatch_event_change(pending) + rescue error + logger.error { error.inspect_with_backtrace } + self[:error_count] = @error_count += 1 + self[:last_error] = { + error: error.message, + time: Time.local.to_s, + user: "flushing event change #{pending.event_id}: #{reason}", + } + ensure + complete.send(nil) + end + end + return unless wait + + deadline = Time.monotonic + wait + flushing.size.times do |index| + remaining = deadline - Time.monotonic + remaining = Time::Span.zero if remaining < Time::Span.zero + + select + when complete.receive + when timeout(remaining) + logger.warn { "timeout flushing pending event changes: #{reason}, #{flushing.size - index} of #{flushing.size} still in flight" } + break + end + end + end + + # Resolves locations, fetches guests and emails visitors about a change. + # Shared by the immediate and debounced paths. + private def dispatch_event_change(change : PendingEventChange) + system_id = change.system_id + previous_system_id = change.previous_system_id + + # Skip a coalesced no-op (e.g. an A->B->A flip-flop that nets to no change). + changed = change.moved_room? + changed = true if (previous_start = change.previous_start) && previous_start != change.current_start + changed = true if (previous_end = change.previous_end) && previous_end != change.current_end + return unless changed + current_building_name = building_zone.display_name.presence || building_zone.name current_room_name = @booking_space_name - current_room_name, current_building_name = resolve_system_location_names(details.system_id, current_room_name, current_building_name) + current_room_name, current_building_name = resolve_system_location_names(system_id, current_room_name, current_building_name) # Default the previous location to the current one; only override it when the # room actually changed. This keeps date/time-only edits showing the same @@ -780,33 +901,25 @@ class Place::VisitorMailer < PlaceOS::Driver previous_building_name = current_building_name previous_room_name = current_room_name - if (prev_sys_id = details.previous_system_id) && prev_sys_id != details.system_id + if change.moved_room? && (prev_sys_id = previous_system_id) # Use "unknown" as the room fallback so a failed lookup surfaces in the # email rather than silently showing the current room name. previous_room_name, previous_building_name = resolve_system_location_names(prev_sys_id, "unknown", current_building_name) end - guests = staff_api.event_guests(details.event_id, details.system_id, details.event_ical_uid).get.as_a + guests = staff_api.event_guests(change.event_id, system_id, change.event_ical_uid).get.as_a send_booking_changed_emails( guests, @event_changed_template, - host, - event_start, - details.title, - details.previous_event_start, + change.host, + change.current_start, + change.title, + change.previous_start, previous_building_name, previous_room_name, current_building_name, current_room_name, ) - rescue error - logger.error { error.inspect_with_backtrace } - self[:error_count] = @error_count += 1 - self[:last_error] = { - error: error.message, - time: Time.local.to_s, - user: payload, - } end # `building_name` / `room_name` override the current location names; when @@ -1066,6 +1179,63 @@ class Place::VisitorMailer < PlaceOS::Driver property parent_id : String? end + # A staff/event/changed change buffered awaiting a debounced flush. + # `current_*` follow the latest signal in the burst, `previous_*` and + # `first_seen` stay as they were when it started, so the email describes the + # net change of the whole edit. + class PendingEventChange + property event_id : String + property system_id : String # the room the event sits in + property event_ical_uid : String? + property host : String + property title : String? + property current_start : Int64 + property current_end : Int64 + property previous_start : Int64? + property previous_end : Int64? + property previous_system_id : String? # the room before the edit + getter first_seen : Time::Span = Time.monotonic + # ical_uid identifies the event instance across mailbox copies and rooms; + # event_id is only a fallback for a signal that omits it. + getter buffer_key : String + + def initialize( + @event_id, + @system_id, + @event_ical_uid, + @host, + @title, + @current_start, + @current_end, + @previous_start, + @previous_end, + @previous_system_id, + ) + @buffer_key = @event_ical_uid.presence || @event_id + end + + # Whether this signal reports the event changing rooms. + def moved_room? : Bool + !!previous_system_id.try { |previous| previous != system_id } + end + + # Advance to the latest signal in the burst. The room only moves when a + # signal reports the move, so a same-room echo from another mailbox can't + # steal it back. + def merge(change : PendingEventChange) : Nil + if change.moved_room? + @event_id = change.event_id + @system_id = change.system_id + @previous_system_id ||= change.previous_system_id + end + @event_ical_uid = change.event_ical_uid || @event_ical_uid + @host = change.host + @title = change.title + @current_start = change.current_start + @current_end = change.current_end + end + end + # zone_id, timeout, zone alias ZoneCache = Hash(String, Tuple(Int64, ZoneDetails)) diff --git a/drivers/place/visitor_mailer_readme.md b/drivers/place/visitor_mailer_readme.md index 7702e3511c..8980ee0981 100644 --- a/drivers/place/visitor_mailer_readme.md +++ b/drivers/place/visitor_mailer_readme.md @@ -26,6 +26,32 @@ Requires the following drivers in the system: skip_host_email: true ``` +## Debouncing event changes + +A single calendar edit is rarely a single signal: Office365 emits a burst of +`staff/event/changed` updates (the organizer copy, then each room mailbox catching +up), which can briefly flip-flop between the old and new values. Sending an email +per signal spams visitors with contradictory notifications. + +`event_change_debounce` (seconds, default `15`) buffers the burst for one event and +sends a single email describing the net change once the window closes. Set it to `0` +to email on every signal. + +```yaml + # Combine duplicate change emails sent within this many seconds; 0 disables. + event_change_debounce: 15 +``` + +Buffered changes are swept on a timer rather than each having its own, so the actual +delay is the configured debounce plus up to one sweep interval (at most 5s). Anything +still buffered is emailed immediately when the driver is unloaded, or when the +debounce is turned off, so a restart never silently drops a pending notification. + +Signals are grouped by event instance (its ical uid), not by room, so an edit that +moves the meeting *and* changes the time sends one email describing both rather than +one per room. A move between buildings is handled by two separate mailer modules and +so still sends an email each. + ## Reply-To Visitor emails set a `Reply-To` header so replies reach a useful person rather than diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index 4a5001479e..8a738fa43d 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -179,6 +179,9 @@ class StaffAPIMock < DriverSpecs::MockDriver case id when "sys-room1" {id: "sys-room1", name: "Room 1", display_name: "Conference Room 1", map_id: nil, zones: ["zone-building", "zone-room"]} + when "sys-room2" + # second room in the SAME building, so signals for it pass the zone filter + {id: "sys-room2", name: "Room 2", display_name: "Conference Room 2", map_id: nil, zones: ["zone-building", "zone-room2"]} when "sys-old-room" {id: "sys-old-room", name: "Room 202", display_name: "Old Conference Room 202", map_id: nil, zones: ["zone-old-building", "zone-old-room"]} when "sys-error" @@ -670,6 +673,15 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do # event_changed_event tests (staff/event/changed) # ================================================================== + # These tests assert an immediate send, so disable the debounce (default 15s). + # send_reminders/domain_uri mirror default_settings so nothing else changes. + settings({ + event_change_debounce: 0, + send_reminders: "0 7 * * *", + domain_uri: "https://example.com/", + }) + sleep 1.0 + # ------------------------------------------------------------------ # Test 11: event_changed with time change — sends booking_changed # emails to all visitors on the event @@ -1174,6 +1186,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do booking_space_name: "Client Floor", invite_zone_tag: "building", skip_event_linked_booking_email: false, + event_change_debounce: 0, }) sleep 1.0 @@ -1376,10 +1389,11 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do # ------------------------------------------------------------------ settings({ - timezone: "GMT", - booking_space_name: "Client Floor", - invite_zone_tag: "building", - skip_host_email: false, + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + skip_host_email: false, + event_change_debounce: 0, }) sleep 1.0 @@ -1480,6 +1494,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do invite_zone_tag: "building", booking_changed_template: "custom_booking_changed", event_changed_template: "custom_event_changed", + event_change_debounce: 0, }) sleep 1.0 @@ -1713,6 +1728,338 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do system(:Mailer)[:last_to].should eq "old-host-n@example.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] system(:Mailer)[:last_args]["event_date"].raw.should be_nil + + # ================================================================== + # event_change_debounce — coalesce the Office365 signal burst (PPT-2375) + # ================================================================== + # + # One edit arrives as an A -> B -> A flip-flop (Wed->Thu, Thu->Wed, Wed->Thu) + # of staff/event/changed signals; the debounce must collapse it into ONE email + # showing the true net change. + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 3, + }) + sleep 1.0 + + gmt = Time::Location.load("GMT") + wed_start = now + 100_000 + thu_start = wed_start + 86_400 # exactly one day later + + # Wed -> Thu (organizer copy) + debounce_signal_a1 = { + action: "update", + system_id: "sys-room1", + event_id: "evt-debounce", + event_ical_uid: "ical-debounce", + host: "host@example.com", + resource: "room1@example.com", + title: "Temporal Uncertainty Forecasts", + event_start: thu_start, + event_end: thu_start + 1800, + zones: ["zone-building", "zone-room"], + previous_event_start: wed_start, + previous_event_end: wed_start + 1800, + }.to_json + + # Thu -> Wed (stale room-mailbox echo — the reversed signal) + debounce_signal_b = { + action: "update", + system_id: "sys-room1", + event_id: "evt-debounce", + event_ical_uid: "ical-debounce", + host: "host@example.com", + resource: "room1@example.com", + title: "Temporal Uncertainty Forecasts", + event_start: wed_start, + event_end: wed_start + 1800, + zones: ["zone-building", "zone-room"], + previous_event_start: thu_start, + previous_event_end: thu_start + 1800, + }.to_json + + # Wed -> Thu (room copy catches up — settled state) + debounce_signal_a2 = { + action: "update", + system_id: "sys-room1", + event_id: "evt-debounce", + event_ical_uid: "ical-debounce", + host: "host@example.com", + resource: "room1@example.com", + title: "Temporal Uncertainty Forecasts", + event_start: thu_start, + event_end: thu_start + 1800, + zones: ["zone-building", "zone-room"], + previous_event_start: wed_start, + previous_event_end: wed_start + 1800, + }.to_json + + count_before_debounce = system(:Mailer)[:send_count].as_i + + publish("staff/event/changed", debounce_signal_a1) + publish("staff/event/changed", debounce_signal_b) + publish("staff/event/changed", debounce_signal_a2) + + # Still inside the 3s window: nothing should have been sent yet. + sleep 1.0 + system(:Mailer)[:send_count].should eq count_before_debounce + + # After the window closes the burst collapses into a single email. + # Allow the debounce plus one sweep interval. + sleep 6.0 + system(:Mailer)[:send_count].should eq count_before_debounce + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] + + # The one email must show the true net change (Wed -> Thu), not the reversed echo. + debounce_args = system(:Mailer)[:last_args] + debounce_args["event_date"].should eq Time.unix(thu_start).in(gmt).to_s("%A, %-d %B") + debounce_args["previous_event_date"].should eq Time.unix(wed_start).in(gmt).to_s("%A, %-d %B") + + # ------------------------------------------------------------------ + # Test 38b: a settings update mid-window neither drops the buffered + # change nor emails it early — the new sweep picks it up. + # ------------------------------------------------------------------ + + survives_signal = { + action: "update", + system_id: "sys-room1", + event_id: "evt-survives-update", + event_ical_uid: "ical-survives-update", + host: "host@example.com", + resource: "room1@example.com", + title: "Settings Update", + event_start: thu_start, + event_end: thu_start + 1800, + zones: ["zone-building", "zone-room"], + previous_event_start: wed_start, + previous_event_end: wed_start + 1800, + }.to_json + + count_before_survives = system(:Mailer)[:send_count].as_i + + publish("staff/event/changed", survives_signal) + sleep 1.0 + system(:Mailer)[:send_count].should eq count_before_survives + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 3, + }) + + # the update must not cut the window short + sleep 0.5 + system(:Mailer)[:send_count].should eq count_before_survives + + sleep 6.0 + system(:Mailer)[:send_count].should eq count_before_survives + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] + + survives_args = system(:Mailer)[:last_args] + survives_args["event_date"].should eq Time.unix(thu_start).in(gmt).to_s("%A, %-d %B") + survives_args["previous_event_date"].should eq Time.unix(wed_start).in(gmt).to_s("%A, %-d %B") + + # ------------------------------------------------------------------ + # Test 38d: the burst is keyed on the ical uid, so two signals for the + # same event instance coalesce even when they report different + # event ids (mailbox copies / duplicate metadata rows). + # ------------------------------------------------------------------ + + count_before_ical = system(:Mailer)[:send_count].as_i + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-copy-a", + event_ical_uid: "ical-shared", + host: "host@example.com", + resource: "room1@example.com", + title: "Shared Ical", + event_start: thu_start, + event_end: thu_start + 1800, + zones: ["zone-building", "zone-room"], + previous_event_start: wed_start, + previous_event_end: wed_start + 1800, + }.to_json) + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-copy-b", + event_ical_uid: "ical-shared", + host: "host@example.com", + resource: "room1@example.com", + title: "Shared Ical", + event_start: thu_start, + event_end: thu_start + 1800, + zones: ["zone-building", "zone-room"], + previous_event_start: wed_start, + previous_event_end: wed_start + 1800, + }.to_json) + + sleep 1.0 + system(:Mailer)[:send_count].should eq count_before_ical + + sleep 6.0 + system(:Mailer)[:send_count].should eq count_before_ical + 1 + system(:Mailer)[:last_args]["event_title"].should eq "Shared Ical" + + # ------------------------------------------------------------------ + # Test 38e: a room move paired with a time change. The old room's + # mailbox echoes the time change against itself; merged with + # the move it must not steal the room back. Move signal first. + # ------------------------------------------------------------------ + + count_before_move = system(:Mailer)[:send_count].as_i + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-move-new", + event_ical_uid: "ical-move", + host: "host@example.com", + resource: "room1@example.com", + title: "Moved And Rescheduled", + event_start: thu_start, + event_end: thu_start + 1800, + zones: ["zone-building", "zone-room"], + previous_event_start: wed_start, + previous_event_end: wed_start + 1800, + previous_system_id: "sys-room2", + }.to_json) + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room2", + event_id: "evt-move-old", + event_ical_uid: "ical-move", + host: "host@example.com", + resource: "room2@example.com", + title: "Moved And Rescheduled", + event_start: thu_start, + event_end: thu_start + 1800, + zones: ["zone-building", "zone-room2"], + previous_event_start: wed_start, + previous_event_end: wed_start + 1800, + previous_system_id: "sys-room2", + }.to_json) + + sleep 7.0 + system(:Mailer)[:send_count].should eq count_before_move + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + + move_args = system(:Mailer)[:last_args] + move_args["room_name"].should eq "Conference Room 1" + move_args["previous_room_name"].should eq "Conference Room 2" + move_args["event_date"].should eq Time.unix(thu_start).in(gmt).to_s("%A, %-d %B") + move_args["previous_event_date"].should eq Time.unix(wed_start).in(gmt).to_s("%A, %-d %B") + + # ------------------------------------------------------------------ + # Test 38f: the same pair in the other order — echo first, then the + # move — must produce the identical email. + # ------------------------------------------------------------------ + + count_before_move_echo = system(:Mailer)[:send_count].as_i + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room2", + event_id: "evt-move2-old", + event_ical_uid: "ical-move-2", + host: "host@example.com", + resource: "room2@example.com", + title: "Echo First", + event_start: thu_start, + event_end: thu_start + 1800, + zones: ["zone-building", "zone-room2"], + previous_event_start: wed_start, + previous_event_end: wed_start + 1800, + previous_system_id: "sys-room2", + }.to_json) + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-move2-new", + event_ical_uid: "ical-move-2", + host: "host@example.com", + resource: "room1@example.com", + title: "Echo First", + event_start: thu_start, + event_end: thu_start + 1800, + zones: ["zone-building", "zone-room"], + previous_event_start: wed_start, + previous_event_end: wed_start + 1800, + previous_system_id: "sys-room2", + }.to_json) + + sleep 7.0 + system(:Mailer)[:send_count].should eq count_before_move_echo + 1 + + move_echo_args = system(:Mailer)[:last_args] + move_echo_args["room_name"].should eq "Conference Room 1" + move_echo_args["previous_room_name"].should eq "Conference Room 2" + + # ------------------------------------------------------------------ + # Test 38g: turning the debounce off flushes whatever is buffered, as no + # sweep will run to pick it up. Same flush as on_unload, which + # the spec harness cannot invoke directly. + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 30, + }) + sleep 1.0 + + drain_signal = { + action: "update", + system_id: "sys-room1", + event_id: "evt-drain", + event_ical_uid: "ical-drain", + host: "host@example.com", + resource: "room1@example.com", + title: "Unload Drain", + event_start: thu_start, + event_end: thu_start + 1800, + zones: ["zone-building", "zone-room"], + previous_event_start: wed_start, + previous_event_end: wed_start + 1800, + }.to_json + + count_before_drain = system(:Mailer)[:send_count].as_i + + publish("staff/event/changed", drain_signal) + + # Well inside the 30s window: the change is buffered, nothing sent yet. + sleep 1.0 + system(:Mailer)[:send_count].should eq count_before_drain + + # Disabling the debounce flushes the buffer instead of orphaning it. + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 0, + }) + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_drain + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] + + drain_args = system(:Mailer)[:last_args] + drain_args["event_date"].should eq Time.unix(thu_start).in(gmt).to_s("%A, %-d %B") + drain_args["previous_event_date"].should eq Time.unix(wed_start).in(gmt).to_s("%A, %-d %B") + # ================================================================== # visitor check-in tests (PPT-2535) # ==================================================================