Skip to content
Merged
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ gem 'dotiw', '>= 4.0.1'
gem 'faraday', '~> 2'
gem 'faker'
gem 'faraday-retry'
gem 'fugit'
gem 'foundation_emails'
gem 'good_job', '~> 3.0'
gem 'http_accept_language'
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,7 @@ DEPENDENCIES
faraday (~> 2)
faraday-retry
foundation_emails
fugit
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fugit is a dependency of goodjob, but make it a first-class citizen now that we're explicitly using it.

good_job (~> 3.0)
http_accept_language
i18n-tasks (~> 1.0)
Expand Down
163 changes: 163 additions & 0 deletions app/services/idv/aamva_state_maintenance_window.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# frozen_string_literal: true

module Idv
class AamvaStateMaintenanceWindow
# _All_ AAMVA maintenance windows are expressed in 'ET' (LG-14028)
TZ = 'America/New_York'

MAINTENANCE_WINDOWS = {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See discussion in this Slack thread -- I initially had these in application.yml, but we decided they made sense in code. They will be changed very infrequently, and a deploy would have been needed anyway to update them.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should verify this assertion by tracking how often this file gets changed in the month or two after deploying it. If we have to edit them multiple times in a month, I think that's a little bit of evidence that they might be better off in config than in source code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that approach.

Realistically, these are currently from a doc from 2023 so I suspect it will not be changed often, unless we decide to move to modeling actual observed downtime vs. planned, recurring maintenance windows.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, what will trigger an update here is getting a new user guide from AAMVA, which doesn't happen all that often (as @n1zyy said, current one is from 2023, and the rev log in there says that they had 3 updates in 2022)

'CA' => [
# Daily, 4:00 - 5:30 am. ET.
{ cron: '0 4 * * *', duration_minutes: 90 },
# Monday, 1:00 - 1:45 am. ET
{ cron: '0 1 * * Mon', duration_minutes: 45 },
# Monday, 1:00 - 4:30 am. ET on 1st and 3rd Monday of month.
{ cron: '0 1 * * Mon#1', duration_minutes: 3.5 * 60 },
{ cron: '0 1 * * Mon#3', duration_minutes: 3.5 * 60 },
],
'CT' => [
# Daily, 4:00 am. to 6:30 am. ET.
{ cron: '0 4 * * *', duration_minutes: 90 },
# Sunday 6:00 am. to 9:30 am. ET
{ cron: '0 6 * * Mon', duration_minutes: 3.5 * 60 },
],
'DC' => [
# Daily, Midnight to 6 am. ET.
{ cron: '0 0 * * *', duration_minutes: 6 * 60 },
],
'DE' => [
# Daily, Midnight to 5 am. ET.
{ cron: '0 0 * * *', duration_minutes: 5 * 60 },
],
'FL' => [
# Sunday 7:00 am. to 12:00 pm. ET
{ cron: '0 7 * * Sun', duration_minutes: 5 * 60 },
],
'IA' => [
# "Daily system resets, normally at 4:45 am. to 5:15 am ET."
{ cron: '45 4 * * *', duration_minutes: 30 },
],
'IN' => [
# Sunday morning maintenance from 6 am. to 10 am. ET.
{ cron: '0 6 * * Sun', duration_minutes: 4 * 60 },
],
'IL' => [
{ cron: '30 2 * * *', duration_minutes: 2.5 * 60 }, # Daily, 2:30 am. to 5 am. ET.
],
'KY' => [
# Daily maintenance from 2:50 am. to 6:40 am. ET
{ cron: '50 2 * * *', duration_minutes: 230 },
],
'MA' => [
# Daily maintenance from 6 am. to 6:15 am. ET.
{ cron: '0 6 * * *', duration_minutes: 15 },
# Wednesday 7 am. to 7:30 am. ET.
{ cron: '0 7 * * Wed', duration_minutes: 30 },
# Saturday 10:00 pm. to Sunday 10:00 am
{ cron: '0 22 * * Sat', duration_minutes: 12 * 60 },
# First Friday of each month: 12 to 6 am. ET.
{ cron: '0 0 * * Fri#1', duration_minutes: 6 * 60 },
],
'MD' => [
# Daily maintenance from 3 am. to 3:15 am. ET.
{ cron: '0 3 * * *', duration_minutes: 15 },
# Sunday maintenance may occur from 6 am. to 10 am. ET.
{ cron: '0 6 * * Sun', duration_minutes: 4 * 60 },
],
'MI' => [
# Daily maintenance from 9 pm. to 9:15 pm. ET.
{ cron: '0 21 * * *', duration_minutes: 15 },
],
'MO' => [
# Daily maintenance from 2 am. to 4:30 am. ...
{ cron: '0 2 * * *', duration_minutes: 2.5 * 60 },
# ... from 6:30 am to 6:45 am ...
{ cron: '30 6 * * *', duration_minutes: 15 },
# ... and 8:30 am. to 8:35 am ET.
{ cron: '30 8 * * *', duration_minutes: 5 },
# Sundays from 9 am. to 10:30 am. ET...
{ cron: '0 9 * * Sun', duration_minutes: 90 },
# ...and 5 am to 5:45 am ET on 2nd Sunday of month.
{ cron: '0 5 * * Sun#2', duration_minutes: 45 },
],
'NC' => [
# Daily, Midnight to 7:00 am. ET.
{ cron: '0 0 * * *', duration_minutes: 7 * 60 },
# Sundays from 5am. till Noon
{ cron: '0 5 * * Sun', duration_minutes: 7 * 60 },
],
# NM: "Sunday mornings." (not modeling; too vague)
'NY' => [
# Sunday maintenance 8 pm. to 9 pm. ET.
{ cron: '0 20 * * Sun', duration_minutes: 60 },
],
'PA' => [
# Sunday maintenance may occur, often between 5:30 am. & 7:00 am. ET
{ cron: '30 5 * * Sun', duration_minutes: 90 },
],
'SC' => [
# Sunday maintenance from 7:00 pm. to 10:00 pm. ET.
{ cron: '0 19 * * Sun', duration_minutes: 3 * 60 },
],
'TX' => [
# Downtime on weekends between 9 pm ET to 7 am ET.
{ cron: '0 21 * * Sat,Sun', duration_minutes: 10 * 60 },
],
'VA' => [
# Sunday morning maintenance 3:00 am. to 5 am. ET.
{ cron: '0 3 * * Sun', duration_minutes: 120 },
# Daily maintenance from 5 am. to 5:30 am.
{ cron: '0 5 * * *', duration_minutes: 30 },
# "Might not respond for short spells, daily between 7 pm and 8:30 pm." (not modeling this)
],
'VT' => [
# Daily maintenance from midnight to 5 am. ET.
{ cron: '0 0 * * *', duration_minutes: 5 * 60 },
],
'WA' => [
# Maintenance from Saturday 9:45 pm. to Sunday 8:15 am. ET.
{ cron: '45 21 * * Sat', duration_minutes: 10.5 * 60 },
],
'WI' => [
# Downtime on Tuesday – Saturday typically between 3 – 4 am ET.
{ cron: '0 3 * * Tue-Sat', duration_minutes: 60 },
# Downtime on Sunday from 6 – 10 am. ET.
{ cron: '0 6 * * Sun', duration_minutes: 4 * 60 },
],
'WV' => [
# Occasional Sunday maintenance from 6:00 am. to noon ET.
{ cron: '0 6 * * Sun', duration_minutes: 6 * 60 },
],
'WY' => [
# Daily, 2 am. to 5 am. ET.
{ cron: '0 2 * * *', duration_minutes: 3 * 60 },
],
}.freeze

PARSED_MAINTENANCE_WINDOWS = MAINTENANCE_WINDOWS.transform_values do |windows|
Time.use_zone(TZ) do
windows.map do |window|
cron = Fugit.parse_cron(window[:cron])
{ cron: cron, duration_minutes: window[:duration_minutes] }
end
end
end.freeze

class << self
def in_maintenance_window?(state)
Time.use_zone(TZ) do
windows_for_state(state).any? { |window| window.cover?(Time.zone.now) }
end
end

def windows_for_state(state)
Time.use_zone(TZ) do
PARSED_MAINTENANCE_WINDOWS.fetch(state, []).map do |window|
previous = window[:cron].previous_time.to_t
(previous..(previous + window[:duration_minutes].minutes))
end
end
end
end
end
end
11 changes: 8 additions & 3 deletions app/services/proofing/aamva/proofer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def proof(applicant)
).send_verification_request(
applicant: aamva_applicant,
)
build_result_from_response(response)
build_result_from_response(response, applicant[:state])
rescue => exception
failed_result = Proofing::StateIdResult.new(
success: false, errors: {}, exception: exception, vendor_name: 'aamva:state_id',
Expand All @@ -61,7 +61,7 @@ def proof(applicant)

private

def build_result_from_response(verification_response)
def build_result_from_response(verification_response, jurisdiction)
Proofing::StateIdResult.new(
success: verification_response.success?,
errors: parse_verification_errors(verification_response),
Expand All @@ -70,11 +70,12 @@ def build_result_from_response(verification_response)
transaction_id: verification_response.transaction_locator_id,
requested_attributes: requested_attributes(verification_response).index_with(1),
verified_attributes: verified_attributes(verification_response),
jurisdiction_in_maintenance_window: jurisdiction_in_maintenance_window?(jurisdiction),
)
end

def parse_verification_errors(verification_response)
errors = errors = Hash.new { |h, k| h[k] = [] }
errors = Hash.new { |h, k| h[k] = [] }

return errors if verification_response.success?

Expand Down Expand Up @@ -119,6 +120,10 @@ def send_to_new_relic(result)
end
NewRelic::Agent.notice_error(result.exception)
end

def jurisdiction_in_maintenance_window?(state)
Idv::AamvaStateMaintenanceWindow.in_maintenance_window?(state)
end
end
end
end
12 changes: 9 additions & 3 deletions app/services/proofing/state_id_result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ class StateIdResult

attr_reader :errors,
:exception,
:success,
:vendor_name,
:transaction_id,
:requested_attributes,
Expand All @@ -21,7 +20,8 @@ def initialize(
vendor_name: nil,
transaction_id: '',
requested_attributes: {},
verified_attributes: []
verified_attributes: [],
jurisdiction_in_maintenance_window: false
)
@success = success
@errors = errors
Expand All @@ -30,10 +30,11 @@ def initialize(
@transaction_id = transaction_id
@requested_attributes = requested_attributes
@verified_attributes = verified_attributes
@jurisdiction_in_maintenance_window = jurisdiction_in_maintenance_window
end

def success?
success
!!@success
end

def timed_out?
Expand All @@ -56,6 +57,10 @@ def mva_exception?
mva_unavailable? || mva_system_error? || mva_timeout?
end

def jurisdiction_in_maintenance_window?
!!@jurisdiction_in_maintenance_window
end
Copy link
Copy Markdown
Contributor Author

@n1zyy n1zyy Aug 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This follows the pattern, but also feels... superfluous? Opinions on just passing through the instance variable where this is used rather than adding a predicate method?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's more common to not define the attr_reader for booleans and just have the predicat method like this

so more like:

    def jurisdiction_in_maintenance_window?
      !!@jurisdiction_in_maintenance_window
    end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was pretty much my instinct, but I followed the pattern of success (lines 38-40) that set up an attr_reader and then have a predicate method that just directly returns the attr_reader value.

(I think my other instinct was to write none of this code and just make line 77 be jurisdiction_in_maintenance_window: !!@jurisidiction_in_maintenance_window.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've also used alias_method for this, though I get the impression that opinions are mixed w.r.t. aliasing.

attr_reader :user_fully_authenticated
alias_method :user_fully_authenticated?, :user_fully_authenticated


def to_h
{
success: success?,
Expand All @@ -67,6 +72,7 @@ def to_h
transaction_id: transaction_id,
vendor_name: vendor_name,
verified_attributes: verified_attributes,
jurisdiction_in_maintenance_window: jurisdiction_in_maintenance_window?,
}
end
end
Expand Down
3 changes: 2 additions & 1 deletion spec/features/idv/analytics_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
verified_attributes: [],
state: 'MT',
state_id_jurisdiction: 'ND',
state_id_number: '#############' }
state_id_number: '#############',
jurisdiction_in_maintenance_window: false }
end

let(:resolution_block) do
Expand Down
68 changes: 68 additions & 0 deletions spec/services/idv/aamva_state_maintenance_window_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
require 'rails_helper'

RSpec.describe Idv::AamvaStateMaintenanceWindow do
let(:tz) { 'America/New_York' }
let(:eastern_time) { ActiveSupport::TimeZone[tz] }

before do
travel_to eastern_time.parse('2024-06-02T00:00:00')
end

describe '#in_maintenance_window?' do
let(:state) { 'DC' }

subject { described_class.in_maintenance_window?(state) }

context 'for a state with a defined outage window' do
it 'is true during the maintenance window' do
travel_to(eastern_time.parse('June 2, 2024 at 1am')) do
expect(subject).to eq(true)
end
end

it 'is false outside of the maintenance window' do
travel_to(eastern_time.parse('June 2, 2024 at 8am')) do
expect(subject).to eq(false)
end
end
end

context 'for a state without a defined outage window' do
let(:state) { 'LG' }

it 'returns false without an exception' do
expect(subject).to eq(false)
end
end
end

describe '.windows_for_state' do
subject { described_class.windows_for_state(state) }

context 'for a state with no entries' do
let(:state) { 'LG' }

it 'returns an empty array for a state with no entries' do
expect(subject).to eq([])
end
end

context 'for a state with multiple overlapping windows' do
let(:state) { 'CA' }
let(:expected_windows) do
[
eastern_time.parse('2024-06-01 04:00:00')..eastern_time.parse('2024-06-01 05:30:00'),
eastern_time.parse('2024-05-27 01:00:00')..eastern_time.parse('2024-05-27 01:45:00'),
eastern_time.parse('2024-05-06 01:00:00')..eastern_time.parse('2024-05-06 04:30:00'),
eastern_time.parse('2024-05-20 01:00:00')..eastern_time.parse('2024-05-20 04:30:00'),
]
end

it 'returns all of them as ranges' do
Time.use_zone(tz) do
expect(subject).to eq(expected_windows)
end
end
end
end
end
24 changes: 24 additions & 0 deletions spec/services/proofing/aamva/proofer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -293,5 +293,29 @@
end
end
end

context 'when the DMV is in a defined maintenance window' do
before do
expect(Idv::AamvaStateMaintenanceWindow).to receive(:in_maintenance_window?).
and_return(true)
end

it 'sets jurisdiction_in_maintenance_window to true' do
result = subject.proof(state_id_data)
expect(result.jurisdiction_in_maintenance_window?).to eq(true)
end
end

context 'when the DMV is not in a defined maintenance window' do
before do
expect(Idv::AamvaStateMaintenanceWindow).to receive(:in_maintenance_window?).
and_return(false)
end

it 'sets jurisdiction_in_maintenance_window to false' do
result = subject.proof(state_id_data)
expect(result.jurisdiction_in_maintenance_window?).to eq(false)
end
end
end
end
Loading