diff --git a/.codex/skills/generate-rails-openapi/SKILL.md b/.codex/skills/generate-rails-openapi/SKILL.md new file mode 100644 index 0000000..06c4c5f --- /dev/null +++ b/.codex/skills/generate-rails-openapi/SKILL.md @@ -0,0 +1,75 @@ +--- +name: generate-rails-openapi +description: Generate or update the OpenAPI specification from the Rails application tests (specs). Use when you need to synchronize the public API contract with the actual implementation in the Rails service. +--- + +# Generate Rails OpenAPI + +Use this skill to automatically generate an OpenAPI specification by running the RSpec integration tests. This ensures that the documentation accurately reflects the behavior of the API. + +## Required Inputs + +- Rails application directory (`rails-app`) +- Integration tests (request specs) in `spec/requests/` +- `rswag` gems (`rswag-api`, `rswag-ui`, `rswag-specs`) installed in the Rails app + +## Workflow + +1. **Ensure dependencies are installed**: + + ```bash + cd rails-app + bundle install + ``` + +2. **Run tests with OpenAPI generation**: + Execute the RSpec tests using the Rswag task. This will run the specs and generate the Swagger file based on the `path`, `post`, `response`, etc. blocks. + + ```bash + cd rails-app + # Generate Swagger documentation + bundle exec rake rswag:specs:swaggerize + ``` + +3. **Locate the generated spec**: + By default, the spec is generated at `swagger/v1/swagger.yaml` (as configured in `spec/swagger_helper.rb`). + +4. **Review**: + Review the generated `swagger.yaml` to ensure it correctly captures the API behavior. + +## Repository Configuration + +- **Gem**: `rswag` is used to capture request/response data and generate the UI. +- **Helper**: `spec/swagger_helper.rb` configures the output path and global metadata (title, version, servers). +- **Test Inclusion**: Ensure request specs use `type: :request` and follow the Rswag DSL. + + ```ruby + # spec/requests/api/v0/my_spec.rb + + require 'swagger_helper' + + RSpec.describe 'api/v0/my_resource', type: :request do + path '/api/v0/my_resource' do + post 'Creates a resource' do + tags 'MyResource' + consumes 'application/json' + parameter name: :resource, in: :body, schema: { ... } + + response '201', 'resource created' do + run_test! + end + end + end + end + + ``` + +## Troubleshooting + +- **No examples found**: Ensure you are running `rake rswag:specs:swaggerize` and that your specs use the Rswag DSL (`path`, `response`, etc.). +- **Database Errors**: Ensure a test database is available or mocked. The app may require `AWS_REGION` and `DB_IAM_AUTH=false` for local tests. +- **Schema Drift**: We use a shared example `swagger_schema_drift_detection` to ensure models and their `to_swagger_schema` definitions stay in sync. Run these via: + + ```bash + bundle exec rspec spec/models + ``` diff --git a/rails-app/.rspec b/rails-app/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/rails-app/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/rails-app/Gemfile b/rails-app/Gemfile index a95aef2..52b80eb 100644 --- a/rails-app/Gemfile +++ b/rails-app/Gemfile @@ -36,7 +36,9 @@ gem "thruster", require: false # gem "rack-cors" group :development, :test do - gem "minitest", "~> 5.0" + gem "rspec-rails", "~> 7.0" + gem "rspec-openapi", group: :test + gem "climate_control" # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" @@ -56,3 +58,5 @@ gem "shoryuken" gem "datadog", "~> 2.0" gem "pg-aws_rds_iam", "~> 0.8.0" + +gem "rswag", "~> 2.17" diff --git a/rails-app/Gemfile.lock b/rails-app/Gemfile.lock index 5e5a293..eb0a0c8 100644 --- a/rails-app/Gemfile.lock +++ b/rails-app/Gemfile.lock @@ -75,6 +75,8 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) aws-eventstream (1.4.0) aws-partitions (1.1245.0) @@ -106,6 +108,7 @@ GEM bundler (>= 1.2.0) thor (~> 1.0) cgi (0.5.1) + climate_control (1.2.0) concurrent-ruby (1.3.6) connection_pool (3.0.2) crass (1.0.6) @@ -121,6 +124,7 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + diff-lcs (1.6.2) dotenv (3.2.0) dotenv-rails (3.2.0) dotenv (= 3.2.0) @@ -149,6 +153,9 @@ GEM reline (>= 0.4.2) jmespath (1.6.2) json (2.19.5) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) jwt (3.1.2) base64 kamal (2.11.0) @@ -245,6 +252,7 @@ GEM psych (5.3.1) date stringio + public_suffix (7.0.5) puma (8.0.1) nio4r (~> 2.0) racc (1.8.1) @@ -299,6 +307,42 @@ GEM regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-openapi (0.26.0) + actionpack (>= 5.2.0) + rails-dom-testing + rspec-core + rspec-rails (7.1.1) + actionpack (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.7) + rswag (2.17.0) + rswag-api (= 2.17.0) + rswag-specs (= 2.17.0) + rswag-ui (= 2.17.0) + rswag-api (2.17.0) + activesupport (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) + rswag-specs (2.17.0) + activesupport (>= 5.2, < 8.2) + json-schema (>= 2.2, < 7.0) + railties (>= 5.2, < 8.2) + rspec-core (>= 2.14) + rswag-ui (2.17.0) + actionpack (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) rubocop (1.86.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -381,17 +425,20 @@ DEPENDENCIES bootsnap brakeman bundler-audit + climate_control datadog (~> 2.0) debug dotenv-rails jwt kamal - minitest (~> 5.0) pg pg-aws_rds_iam (~> 0.8.0) puma (>= 5.0) rails (~> 8.1.3) redis + rspec-openapi + rspec-rails (~> 7.0) + rswag (~> 2.17) rubocop-rails-omakase shoryuken stoplight (~> 5.8) diff --git a/rails-app/app/models/education/enrollment_request.rb b/rails-app/app/models/education/enrollment_request.rb index 85c4e9d..2b8d6fd 100644 --- a/rails-app/app/models/education/enrollment_request.rb +++ b/rails-app/app/models/education/enrollment_request.rb @@ -11,6 +11,38 @@ def initialize(params = {}) @address = params[:address] end + def missing_required_field? + to_swagger_spec.any? { |field| field[:required] && field[:name].to_s.downcase.to_sym == :firstName && self.send(field[:name].to_s.downcase.to_sym).blank? } + end + + def self.to_swagger_schema + { + type: :object, + required: %i[firstName dateOfBirth lastName], + properties: { + firstName: { type: :string, description: "First name of the student.", example: "John" }, + middleName: { type: :string, description: "Middle name of the student.", example: "Quincy" }, + lastName: { type: :string, description: "Last name of the student.", example: "Doe" }, + dateOfBirth: { type: :string, description: "Date of birth of the student (YYYY-MM-DD).", example: "1990-01-01" }, + ssn: { type: :string, description: "Social Security Number of the student.", example: "000-00-0000" }, + address: { + type: :object, + description: "Postal address when a lookup requires demographic matching instead of SSN matching.", + properties: { + street1: { type: :string, description: "Primary street address line.", example: "123 Main St" }, + street2: { type: :string, description: "Secondary street address line when available.", example: "Apt 4B" }, + street3: { type: :string, description: "Additional street address line when available." }, + city: { type: :string, description: "City or locality.", example: "Arlington" }, + state: { type: :string, description: "State, province, or region code.", example: "VA" }, + postalCode: { type: :string, description: "Postal or ZIP code.", example: "22202" }, + country: { type: :string, description: "Country name or code.", example: "USA" } + }, + required: %i[street1 city state postalCode country] + } + } + } + end + def to_nsc_payload(account_id) out = { accountId: account_id, diff --git a/rails-app/app/models/education/enrollment_response.rb b/rails-app/app/models/education/enrollment_response.rb index 451ea35..284e74c 100644 --- a/rails-app/app/models/education/enrollment_response.rb +++ b/rails-app/app/models/education/enrollment_response.rb @@ -10,6 +10,44 @@ def initialize(params = {}) @metadata = params[:metadata] end + def self.to_swagger_schema + { + type: :object, + properties: { + enrollmentStatus: { + type: :string, + description: "Aggregated enrollment status across all schools.", + enum: EnrollmentStatus::RANKS.keys, + example: "FULL_TIME" + }, + enrollmentDetails: { + type: :array, + items: { + type: :object, + properties: { + schoolName: { type: :string, description: "Official name of the educational institution.", example: "University of Excellence" }, + termBeginDate: { type: :string, description: "Start date of the academic term (YYYY-MM-DD).", example: "2023-01-15" }, + termEndDate: { type: :string, description: "End date of the academic term (YYYY-MM-DD).", example: "2023-05-20" }, + enrollmentStatus: { + type: :string, + description: "Enrollment status for this specific term.", + enum: EnrollmentStatus::RANKS.keys, + example: "FULL_TIME" + } + } + } + }, + dataSource: { type: :string, description: "The source of the enrollment data (e.g., NSC).", example: "NSC" }, + metadata: { + type: :object, + properties: { + durationMs: { type: :integer, description: "Time taken to fetch data from the source in milliseconds.", example: 125 } + } + } + } + } + end + def as_json(options = {}) { enrollmentStatus: enrollment_status, diff --git a/rails-app/config/database.yml b/rails-app/config/database.yml index 99743ee..ab03225 100644 --- a/rails-app/config/database.yml +++ b/rails-app/config/database.yml @@ -9,7 +9,6 @@ default: &default pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> sslmode: <%= ENV.fetch("DB_SSLMODE") { "prefer" } %> iam_auth: <%= ENV.fetch("DB_IAM_AUTH") { "false" } %> - aws_rds_iam_auth_token_generator: default development: <<: *default @@ -17,7 +16,9 @@ development: test: <<: *default database: <%= ENV.fetch("DB_NAME") { "postgres" } %>_test + iam_auth: false production: <<: *default iam_auth: <%= ENV.fetch("DB_IAM_AUTH") { "false" } %> + aws_rds_iam_auth_token_generator: default diff --git a/rails-app/config/initializers/rswag_api.rb b/rails-app/config/initializers/rswag_api.rb new file mode 100644 index 0000000..c4462b2 --- /dev/null +++ b/rails-app/config/initializers/rswag_api.rb @@ -0,0 +1,14 @@ +Rswag::Api.configure do |c| + + # Specify a root folder where Swagger JSON files are located + # This is used by the Swagger middleware to serve requests for API descriptions + # NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure + # that it's configured to generate files in the same folder + c.openapi_root = Rails.root.to_s + '/swagger' + + # Inject a lambda function to alter the returned Swagger prior to serialization + # The function will have access to the rack env for the current request + # For example, you could leverage this to dynamically assign the "host" property + # + #c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] } +end diff --git a/rails-app/config/initializers/rswag_ui.rb b/rails-app/config/initializers/rswag_ui.rb new file mode 100644 index 0000000..9b93091 --- /dev/null +++ b/rails-app/config/initializers/rswag_ui.rb @@ -0,0 +1,16 @@ +Rswag::Ui.configure do |c| + + # List the Swagger endpoints that you want to be documented through the + # swagger-ui. The first parameter is the path (absolute or relative to the UI + # host) to the corresponding endpoint and the second is a title that will be + # displayed in the document selector. + # NOTE: If you're using rspec-api to expose Swagger files + # (under openapi_root) as JSON or YAML endpoints, then the list below should + # correspond to the relative paths for those endpoints. + + c.openapi_endpoint '/api-docs/v1/swagger.yaml', 'API V1 Docs' + + # Add Basic Auth in case your API is private + # c.basic_auth_enabled = true + # c.basic_auth_credentials 'username', 'password' +end diff --git a/rails-app/config/routes.rb b/rails-app/config/routes.rb index e1abc26..5343b8a 100644 --- a/rails-app/config/routes.rb +++ b/rails-app/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + mount Rswag::Ui::Engine => '/api-docs' + mount Rswag::Api::Engine => '/api-docs' # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/rails-app/spec/jobs/reporting/report_job_spec.rb b/rails-app/spec/jobs/reporting/report_job_spec.rb new file mode 100644 index 0000000..279f7df --- /dev/null +++ b/rails-app/spec/jobs/reporting/report_job_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +module Reporting + RSpec.describe ReportJob, type: :job do + describe '#perform' do + it 'performs and creates an ApiEvent' do + body = { + timestamp: Time.now.iso8601, + endpoint: '/api/v0/test', + data_source: 'test-source', + client_id: 'test-client', + status_code: 200, + success: true + }.to_json + + expect { + ReportJob.new.perform(nil, body) + }.to change(ApiEvent, :count).by(1) + + event = ApiEvent.last + expect(event.endpoint).to eq('/api/v0/test') + expect(event.data_source).to eq('test-source') + expect(event.client_id).to eq('test-client') + expect(event.status_code).to eq(200) + expect(event.success).to be true + end + + it 'logs error and raises if JSON is invalid' do + expect { + ReportJob.new.perform(nil, 'invalid json') + }.to raise_error(JSON::ParserError) + end + end + end +end diff --git a/rails-app/spec/models/education/enrollment_request_spec.rb b/rails-app/spec/models/education/enrollment_request_spec.rb new file mode 100644 index 0000000..6f43a63 --- /dev/null +++ b/rails-app/spec/models/education/enrollment_request_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +module Education + RSpec.describe EnrollmentRequest, type: :model do + it_behaves_like 'swagger_schema_drift_detection', EnrollmentRequest + end +end diff --git a/rails-app/spec/models/education/enrollment_response_spec.rb b/rails-app/spec/models/education/enrollment_response_spec.rb new file mode 100644 index 0000000..9b83861 --- /dev/null +++ b/rails-app/spec/models/education/enrollment_response_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +module Education + RSpec.describe EnrollmentResponse, type: :model do + it_behaves_like 'swagger_schema_drift_detection', EnrollmentResponse, ignored_attributes: [:raw_data] + end +end diff --git a/rails-app/spec/models/veteran/disability_rating_request_spec.rb b/rails-app/spec/models/veteran/disability_rating_request_spec.rb new file mode 100644 index 0000000..6931894 --- /dev/null +++ b/rails-app/spec/models/veteran/disability_rating_request_spec.rb @@ -0,0 +1,71 @@ +require 'rails_helper' + +module Veteran + RSpec.describe DisabilityRatingRequest, type: :model do + describe '#to_va_payload' do + it 'maps fields correctly' do + params = { + firstName: 'John', + middleName: 'M', + lastName: 'Doe', + dateOfBirth: '1990-01-01', + ssn: '999999999', + address: { + street1: '123 Main St', + city: 'Anytown', + state: 'NY', + postalCode: '12345', + country: 'USA' + } + } + req = DisabilityRatingRequest.new(params) + payload = req.to_va_payload + + expect(payload[:first_name]).to eq('John') + expect(payload[:middle_name]).to eq('M') + expect(payload[:last_name]).to eq('Doe') + expect(payload[:birth_date]).to eq('1990-01-01') + expect(payload[:ssn]).to eq('999999999') + expect(payload[:street_address_line1]).to eq('123 Main St') + expect(payload[:city]).to eq('Anytown') + expect(payload[:state]).to eq('NY') + expect(payload[:zipcode]).to eq('12345') + expect(payload[:country]).to eq('USA') + end + + it 'omits optional fields' do + params = { + firstName: 'John', + lastName: 'Doe', + dateOfBirth: '1990-01-01' + } + req = DisabilityRatingRequest.new(params) + payload = req.to_va_payload + + expect(payload[:first_name]).to eq('John') + expect(payload[:last_name]).to eq('Doe') + expect(payload[:birth_date]).to eq('1990-01-01') + expect(payload[:middle_name]).to be_nil + expect(payload[:ssn]).to be_nil + expect(payload[:street_address_line1]).to be_nil + end + end + + describe '#can_use_restricted_endpoint?' do + it 'returns true when ssn is present' do + req = DisabilityRatingRequest.new(ssn: '999999999') + expect(req.can_use_restricted_endpoint?).to be true + end + + it 'returns false when ssn is absent' do + req = DisabilityRatingRequest.new + expect(req.can_use_restricted_endpoint?).to be false + end + + it 'returns false when ssn is empty' do + req = DisabilityRatingRequest.new(ssn: '') + expect(req.can_use_restricted_endpoint?).to be false + end + end + end +end diff --git a/rails-app/spec/rails_helper.rb b/rails-app/spec/rails_helper.rb new file mode 100644 index 0000000..40eb15d --- /dev/null +++ b/rails-app/spec/rails_helper.rb @@ -0,0 +1,69 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' + +# Mock AWS credentials and region to avoid MissingCredentialsError/MissingRegionError during tests +require_relative '../config/environment' + +require 'rspec/rails' +# Add additional requires below this line. Rails is not loaded until this point! + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are +# run as spec files by default. This means that files in spec/support that end +# in _spec.rb will both be required and run as specs, causing the specs to be +# run twice. It is recommended that you do not name files matching this glob to +# end with _spec.rb. You can configure this pattern with the --pattern +# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. +# +# The following line is provided for convenience purposes. It has the downside +# of increasing the boot-up time by auto-requiring all files in the support +# directory. Alternatively, in the individual `*_spec.rb` files, manually +# require only the support files necessary. +# +# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } +Dir[Rails.root.join('spec/support/**/*.rb')].sort.each { |f| require f } + +# Checks for pending migrations and applies them before tests are run. +# If you are not using ActiveRecord, you can remove these lines. +# begin +# ActiveRecord::Migration.maintain_test_schema! +# rescue ActiveRecord::PendingMigrationError => e +# abort e.to_s.strip +# end +RSpec.configure do |config| + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_paths = [ + Rails.root.join('spec/fixtures') + ] + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # You can uncomment this line to turn off ActiveRecord support entirely. + # config.use_active_record = false + + # RSpec Rails uses metadata to mix in different behaviours to your tests, + # for example enabling you to call `get` and `post` in request specs. e.g.: + # + # RSpec.describe UsersController, type: :request do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://rspec.info/features/7-1/rspec-rails + # + # You can also this infer these behaviours automatically by location, e.g. + # /spec/models would pull in the same behaviour as `type: :model` but this + # behaviour is considered legacy and will be removed in a future version. + # + # To enable this behaviour uncomment the line below. + # config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") +end diff --git a/rails-app/spec/requests/api/v0/education_enrollments_spec.rb b/rails-app/spec/requests/api/v0/education_enrollments_spec.rb new file mode 100644 index 0000000..cc8501b --- /dev/null +++ b/rails-app/spec/requests/api/v0/education_enrollments_spec.rb @@ -0,0 +1,76 @@ +require 'swagger_helper' + +RSpec.describe 'api/v0/education_enrollments', type: :request do + + path '/api/v0/education-enrollments' do + + before do + fake_result = { + enrollmentStatus: "FULL_TIME", + dataSource: "NSC", + metadata: { + durationMs: 12 + } + } + coordinator_mock = instance_double(Education::ServiceCoordinator) + expect(coordinator_mock).to receive(:lookup_enrollment_status) + .with( + { + firstName: "John", + lastName: "Doe", + dateOfBirth: "1990-01-01" + } + ) + .and_return(fake_result) + + allow(Education::ServiceCoordinator).to receive(:new).and_return(coordinator_mock) + end + + post('create education-enrollment') do + security [oauth2: []] + let(:Authorization) { 'Bearer ' } + consumes 'application/json' + produces 'application/json' + parameter name: :education_enrollment, + in: :body, + schema: Education::EnrollmentRequest.to_swagger_schema + + response(200, 'successful') do + schema Education::EnrollmentResponse.to_swagger_schema + + let(:education_enrollment) do + { + firstName: 'John', + lastName: 'Doe', + dateOfBirth: '1990-01-01' + } + end + + example 'application/json', :successful_enrollment_lookup, { + enrollmentStatus: "FULL_TIME", + enrollmentDetails: [ + { + schoolName: "University of Excellence", + termBeginDate: "2023-01-15", + termEndDate: "2023-05-20", + enrollmentStatus: "FULL_TIME" + } + ], + dataSource: "NSC", + metadata: { + durationMs: 125 + } + } + + after do |example| + example.metadata[:response][:content] = { + 'application/json' => { + example: JSON.parse(response.body, symbolize_names: true) + } + } + end + run_test! + end + end + end +end diff --git a/rails-app/spec/requests/health_spec.rb b/rails-app/spec/requests/health_spec.rb new file mode 100644 index 0000000..fc68bf6 --- /dev/null +++ b/rails-app/spec/requests/health_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +RSpec.describe "Health", type: :request do + describe "GET /health" do + it "should get health" do + redis_mock = instance_double(Redis) + allow(redis_mock).to receive(:ping).and_return("PONG") + allow(redis_mock).to receive(:close) + allow(Redis).to receive(:new).and_return(redis_mock) + + get "/health" + expect(response).to have_http_status(:success) + end + + it "should return service_unavailable when redis is down" do + redis_mock = instance_double(Redis) + allow(redis_mock).to receive(:ping).and_raise(Redis::CannotConnectError, "Error connecting to Redis") + allow(redis_mock).to receive(:close) + allow(Redis).to receive(:new).and_return(redis_mock) + + get "/health" + expect(response).to have_http_status(:service_unavailable) + end + end +end diff --git a/rails-app/spec/services/education/nsc_client_spec.rb b/rails-app/spec/services/education/nsc_client_spec.rb new file mode 100644 index 0000000..0215841 --- /dev/null +++ b/rails-app/spec/services/education/nsc_client_spec.rb @@ -0,0 +1,136 @@ +require 'rails_helper' +require 'net/http' + +module Education + RSpec.describe NscClient, type: :service do + let(:env_overrides) do + { + 'NSC_SUBMIT_URL' => 'https://example.test/submit', + 'NSC_ACCOUNT_ID' => '12345', + 'NSC_CLIENT_ID' => 'client-id', + 'NSC_CLIENT_SECRET' => 'client-secret', + 'NSC_TOKEN_URL' => 'https://example.test/token' + } + end + + around do |example| + ClimateControl.modify(env_overrides) do + example.run + end + end + + let(:client) { NscClient.new } + let(:req_body) do + { + firstName: 'Lynette', + lastName: 'Oyola', + dateOfBirth: '1988-10-24' + } + end + + def stub_nsc_requests(oauth_resp, submit_resp) + http_mock_oauth = instance_double(Net::HTTP) + allow(http_mock_oauth).to receive(:use_ssl=).with(true) + allow(http_mock_oauth).to receive(:request).with(instance_of(Net::HTTP::Post)).and_return(oauth_resp) + + http_mock_submit = instance_double(Net::HTTP) + allow(http_mock_submit).to receive(:use_ssl=).with(true) + allow(http_mock_submit).to receive(:request).with(instance_of(Net::HTTP::Post)).and_return(submit_resp) + + expect(Net::HTTP).to receive(:new).and_return(http_mock_oauth, http_mock_submit) + end + + describe '#lookup_enrollment_status' do + it 'success with positive hit' do + oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(oauth_response).to receive(:body).and_return({ access_token: 'fake-token' }.to_json) + + submit_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(submit_response).to receive(:body).and_return({ + status: { code: '0', message: 'Successful', severity: 'Info' }, + transactionDetails: { nscHit: 'Y', transactionStatus: 'CNF' }, + enrollmentDetails: [{ currentEnrollmentStatus: 'CC' }] + }.to_json) + + stub_nsc_requests(oauth_response, submit_response) + + result = client.lookup_enrollment_status(req_body).as_json + + expect(result[:enrollmentStatus]).to eq('ENROLLMENT_STATUS_UNKNOWN_CREDIT_TIMING') + expect(result[:dataSource]).to eq('NSC') + expect(result[:metadata]).not_to be_nil + end + + it 'maps specific enrollment status' do + oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(oauth_response).to receive(:body).and_return({ access_token: 'fake-token' }.to_json) + + submit_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(submit_response).to receive(:body).and_return({ + transactionDetails: { nscHit: 'Y' }, + enrollmentDetails: [{ + currentEnrollmentStatus: 'CC', + officialSchoolName: 'University A', + enrollmentData: [{ enrollmentStatus: 'H', termBeginDate: '2023-01-01', termEndDate: '2023-05-01' }] + }] + }.to_json) + + stub_nsc_requests(oauth_response, submit_response) + + result = client.lookup_enrollment_status(req_body).as_json + + expect(result[:enrollmentStatus]).to eq('HALF_TIME') + expect(result[:enrollmentDetails].size).to eq(1) + expect(result[:enrollmentDetails][0][:enrollmentStatus]).to eq('HALF_TIME') + expect(result[:enrollmentDetails][0][:schoolName]).to eq('University A') + end + + it 'raises Not Found for no hit' do + oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(oauth_response).to receive(:body).and_return({ access_token: 'fake-token' }.to_json) + + submit_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(submit_response).to receive(:body).and_return({ + transactionDetails: { nscHit: 'N' }, + enrollmentDetails: [{ currentEnrollmentStatus: 'CN' }] + }.to_json) + + stub_nsc_requests(oauth_response, submit_response) + + expect { + client.lookup_enrollment_status(req_body) + }.to raise_error(Education::NotFoundError) + end + + it 'raises Not Found for currently not enrolled (CN)' do + oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(oauth_response).to receive(:body).and_return({ access_token: 'fake-token' }.to_json) + + submit_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(submit_response).to receive(:body).and_return({ + transactionDetails: { nscHit: 'Y' }, + enrollmentDetails: [{ currentEnrollmentStatus: 'CN' }] + }.to_json) + + stub_nsc_requests(oauth_response, submit_response) + + expect { + client.lookup_enrollment_status(req_body) + }.to raise_error(Education::NotFoundError) + end + + it 'raises error for non-2xx response' do + oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(oauth_response).to receive(:body).and_return({ access_token: 'fake-token' }.to_json) + + submit_response = Net::HTTPBadGateway.new('1.1', '502', 'Bad Gateway') + + stub_nsc_requests(oauth_response, submit_response) + + expect { + client.lookup_enrollment_status(req_body) + }.to raise_error(StandardError, /NSC submit failed: status=502/) + end + end + end +end diff --git a/rails-app/spec/services/veteran/va_client_spec.rb b/rails-app/spec/services/veteran/va_client_spec.rb new file mode 100644 index 0000000..933d3c5 --- /dev/null +++ b/rails-app/spec/services/veteran/va_client_spec.rb @@ -0,0 +1,137 @@ +require 'rails_helper' +require 'net/http' + +module Veteran + RSpec.describe VaClient, type: :service do + let(:env_overrides) do + { + 'VA_BASE_URL' => 'https://example.test/va', + 'VA_TOKEN_URL' => 'https://example.test/token', + 'VA_CLIENT_ID' => 'client-id', + 'VA_AUD' => 'https://example.test/aud', + 'VA_PRIVATE_KEY_PATH' => 'spec/fixtures/files/test.key', + 'SERVICE_VERSION' => '1.3.0', + 'ENVIRONMENT' => 'test' + } + end + + around do |example| + # Create a dummy private key for testing + key = OpenSSL::PKey::RSA.new(2048) + FileUtils.mkdir_p('spec/fixtures/files') + File.write('spec/fixtures/files/test.key', key.to_pem) + + ClimateControl.modify(env_overrides) do + example.run + end + + File.delete('spec/fixtures/files/test.key') if File.exist?('spec/fixtures/files/test.key') + end + + let(:client) { VaClient.new } + let(:req_params) do + { + firstName: 'Lynette', + lastName: 'Oyola', + dateOfBirth: '1988-10-24', + ssn: '123456789' + } + end + + before do + Current.request_id = 'test-request-id' + end + + def stub_va_requests(oauth_resp, va_total_disability_resp = nil, total_disability_path: nil) + http_mock_oauth = instance_double(Net::HTTP) + allow(http_mock_oauth).to receive(:use_ssl=).with(true) + allow(http_mock_oauth).to receive(:request).with(instance_of(Net::HTTP::Post)).and_return(oauth_resp) + + http_mock_va = instance_double(Net::HTTP) + allow(http_mock_va).to receive(:use_ssl=).with(true) + + if va_total_disability_resp + allow(http_mock_va).to receive(:request) do |req| + if total_disability_path.nil? || req.path == total_disability_path + va_total_disability_resp + else + # Fallback for rating request if needed, though the client calls it in order + nil + end + end + end + + expect(Net::HTTP).to receive(:new).and_return(http_mock_oauth, http_mock_va) + end + + describe '#lookup_disability_rating' do + it 'success uses restricted endpoint when SSN is present' do + oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(oauth_response).to receive(:body).and_return({ access_token: 'fake-token' }.to_json) + + va_total_disability_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(va_total_disability_response).to receive(:body).and_return({ + data: { + total_disability: { + status: true, + effective_date: "2023-01-01" + }, + permanent_and_total: { + service_connected_status: false, + pension_award_status: false + } + } + }.to_json) + + stub_va_requests(oauth_response, va_total_disability_response, + total_disability_path: '/va/restricted/permanent_and_total_disability') + + response = client.lookup_disability_rating(req_params) + expect(response.total_disability_status).to be true + expect(response.total_disability_status_effective_date).to be_present + expect(response.metadata[:transactionId]).to eq('test-request-id') + end + + it 'success uses standard endpoint when SSN is absent' do + params_without_ssn = req_params.except(:ssn) + + oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(oauth_response).to receive(:body).and_return({ access_token: 'fake-token' }.to_json) + + va_total_disability_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(va_total_disability_response).to receive(:body).and_return({ + data: { + total_disability: { + status: true, + effective_date: "2023-01-01" + }, + permanent_and_total: { + service_connected_status: true, + pension_award_status: true + } + } + }.to_json) + + stub_va_requests(oauth_response, va_total_disability_response, + total_disability_path: '/va/permanent_and_total_disability') + + response = client.lookup_disability_rating(params_without_ssn) + expect(response.total_disability_status).to be true + expect(response.total_disability_status_effective_date).to be_present + end + + it 'raises NotFoundError on 404' do + oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') + allow(oauth_response).to receive(:body).and_return({ access_token: 'fake-token' }.to_json) + + va_response = Net::HTTPNotFound.new('1.1', '404', 'Not Found') + + stub_va_requests(oauth_response, va_response) + + expect { + client.lookup_disability_rating(req_params) + }.to raise_error(Veteran::NotFoundError) + end + end + end +end diff --git a/rails-app/spec/spec_helper.rb b/rails-app/spec/spec_helper.rb new file mode 100644 index 0000000..327b58e --- /dev/null +++ b/rails-app/spec/spec_helper.rb @@ -0,0 +1,94 @@ +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + config.disable_monkey_patching! + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end diff --git a/rails-app/spec/support/swagger_schema_drift_examples.rb b/rails-app/spec/support/swagger_schema_drift_examples.rb new file mode 100644 index 0000000..38af27e --- /dev/null +++ b/rails-app/spec/support/swagger_schema_drift_examples.rb @@ -0,0 +1,52 @@ +RSpec.shared_examples 'swagger_schema_drift_detection' do |model_class, options = {}| + let(:schema) { model_class.to_swagger_schema } + let(:properties) { schema[:properties].keys } + let(:ignored_attributes) { options[:ignored_attributes] || [] } + + it 'includes all attributes in the swagger schema' do + # This test ensures that fields documented in Swagger are actually handled by the model + properties.each do |property| + # Map camelCase to snake_case for the attribute check + attr_name = property.to_s.underscore.to_sym + + expect(model_class.new).to respond_to(attr_name), "Expected #{model_class} to have attribute :#{attr_name} (from swagger property :#{property})" + end + end + + it 'maps all swagger properties in initialize' do + # Create a hash with all properties from the schema + params = properties.each_with_object({}) do |prop, hash| + if prop == :enrollmentDetails + hash[prop] = [{ schoolName: "Test School" }] + elsif prop == :metadata + hash[prop] = { durationMs: 100 } + elsif prop == :address + hash[prop] = { street1: "123 Main St", city: "Arlington", state: "VA", postalCode: "22202", country: "USA" } + else + hash[prop] = "test_value_for_#{prop}" + end + end + + instance = model_class.new(params) + + properties.each do |property| + attr_name = property.to_s.underscore + value = instance.send(attr_name) + expected_value = params[property] + expect(value).to eq(expected_value), "Expected attribute :#{attr_name} to be set from swagger property :#{property}" + end + end + + it 'warns if there are attributes not documented in swagger' do + # This is the "drift" check in the other direction - code has it but swagger doesn't + # Get all attr_accessors (public methods that have a setter equivalent) + model_instance = model_class.new + model_attributes = model_instance.public_methods(false).select { |m| m.to_s.end_with?('=') }.map { |m| m.to_s.chomp('=').to_sym } + + documented_attributes = properties.map { |p| p.to_s.underscore.to_sym } + + missing_from_swagger = model_attributes - documented_attributes - ignored_attributes + + expect(missing_from_swagger).to be_empty, "The following model attributes are not documented in .to_swagger_schema: #{missing_from_swagger.join(', ')}" + end +end diff --git a/rails-app/spec/swagger_helper.rb b/rails-app/spec/swagger_helper.rb new file mode 100644 index 0000000..e094840 --- /dev/null +++ b/rails-app/spec/swagger_helper.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.configure do |config| + # Specify a root folder where Swagger JSON files are generated + # NOTE: If you're using the rswag-api to serve API descriptions, you'll need + # to ensure that it's configured to serve Swagger from the same folder + config.openapi_root = Rails.root.join('swagger').to_s + + # Define one or more Swagger documents and provide global metadata for each one + # When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will + # be generated at the provided relative path under openapi_root + # By default, the operations defined in spec files are added to the first + # document below. You can override this behavior by adding a openapi_spec tag to the + # the root example_group in your specs, e.g. describe '...', openapi_spec: 'v2/swagger.json' + config.openapi_specs = { + 'v1/swagger.yaml' => { + openapi: '3.0.1', + info: { + title: 'API V1', + version: 'v1' + }, + paths: {}, + components: { + securitySchemes: { + oauth2: { + type: :oauth2, + flows: { + clientCredentials: { + tokenUrl: '/oauth/token', + scopes: {} + } + } + } + } + }, + servers: [ + { + url: 'https://{defaultHost}', + variables: { + defaultHost: { + default: 'api.uat.emmy.cms.gov' + } + } + } + ] + } + } + + # Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'. + # The openapi_specs configuration option has the filename including format in + # the key, this may want to be changed to avoid putting yaml in json files. + # Defaults to json. Accepts ':json' and ':yaml'. + config.openapi_format = :yaml +end diff --git a/rails-app/swagger/v1/swagger.yaml b/rails-app/swagger/v1/swagger.yaml new file mode 100644 index 0000000..f15ea0c --- /dev/null +++ b/rails-app/swagger/v1/swagger.yaml @@ -0,0 +1,161 @@ +--- +openapi: 3.0.1 +info: + title: API V1 + version: v1 +paths: + "/api/v0/education-enrollments": + post: + summary: create education-enrollment + security: + - oauth2: [] + parameters: [] + responses: + '200': + description: successful + content: + application/json: + examples: + successful_enrollment_lookup: + value: + enrollmentStatus: FULL_TIME + enrollmentDetails: + - schoolName: University of Excellence + termBeginDate: '2023-01-15' + termEndDate: '2023-05-20' + enrollmentStatus: FULL_TIME + dataSource: NSC + metadata: + durationMs: 125 + schema: + type: object + properties: + enrollmentStatus: + type: string + description: Aggregated enrollment status across all schools. + enum: + - FULL_TIME + - THREE_QUARTERS_TIME + - HALF_TIME + - LESS_THAN_HALF_TIME + - ENROLLMENT_STATUS_UNKNOWN_CREDIT_TIMING + example: FULL_TIME + enrollmentDetails: + type: array + items: + type: object + properties: + schoolName: + type: string + description: Official name of the educational institution. + example: University of Excellence + termBeginDate: + type: string + description: Start date of the academic term (YYYY-MM-DD). + example: '2023-01-15' + termEndDate: + type: string + description: End date of the academic term (YYYY-MM-DD). + example: '2023-05-20' + enrollmentStatus: + type: string + description: Enrollment status for this specific term. + enum: + - FULL_TIME + - THREE_QUARTERS_TIME + - HALF_TIME + - LESS_THAN_HALF_TIME + - ENROLLMENT_STATUS_UNKNOWN_CREDIT_TIMING + example: FULL_TIME + dataSource: + type: string + description: The source of the enrollment data (e.g., NSC). + example: NSC + metadata: + type: object + properties: + durationMs: + type: integer + description: Time taken to fetch data from the source in milliseconds. + example: 125 + requestBody: + content: + application/json: + schema: + type: object + required: + - firstName + - dateOfBirth + - lastName + properties: + firstName: + type: string + description: First name of the student. + example: John + middleName: + type: string + description: Middle name of the student. + example: Quincy + lastName: + type: string + description: Last name of the student. + example: Doe + dateOfBirth: + type: string + description: Date of birth of the student (YYYY-MM-DD). + example: '1990-01-01' + ssn: + type: string + description: Social Security Number of the student. + example: 000-00-0000 + address: + type: object + description: Postal address when a lookup requires demographic matching + instead of SSN matching. + properties: + street1: + type: string + description: Primary street address line. + example: 123 Main St + street2: + type: string + description: Secondary street address line when available. + example: Apt 4B + street3: + type: string + description: Additional street address line when available. + city: + type: string + description: City or locality. + example: Arlington + state: + type: string + description: State, province, or region code. + example: VA + postalCode: + type: string + description: Postal or ZIP code. + example: '22202' + country: + type: string + description: Country name or code. + example: USA + required: + - street1 + - city + - state + - postalCode + - country +components: + securitySchemes: + oauth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: "/oauth/token" + scopes: {} +servers: +- url: https://{defaultHost} + variables: + defaultHost: + default: api.uat.emmy.cms.gov diff --git a/rails-app/test/controllers/.keep b/rails-app/test/controllers/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/rails-app/test/fixtures/files/.keep b/rails-app/test/fixtures/files/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/rails-app/test/integration/.keep b/rails-app/test/integration/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/rails-app/test/jobs/reporting/report_job_test.rb b/rails-app/test/jobs/reporting/report_job_test.rb deleted file mode 100644 index 5c03037..0000000 --- a/rails-app/test/jobs/reporting/report_job_test.rb +++ /dev/null @@ -1,33 +0,0 @@ -require 'test_helper' - -module Reporting - class ReportJobTest < ActiveSupport::TestCase - test 'performs and creates an ApiEvent' do - body = { - timestamp: Time.now.iso8601, - endpoint: '/api/v0/test', - data_source: 'test-source', - client_id: 'test-client', - status_code: 200, - success: true - }.to_json - - assert_difference 'ApiEvent.count', 1 do - Reporting::ReportJob.new.perform(nil, body) - end - - event = ApiEvent.last - assert_equal '/api/v0/test', event.endpoint - assert_equal 'test-source', event.data_source - assert_equal 'test-client', event.client_id - assert_equal 200, event.status_code - assert_equal true, event.success - end - - test 'logs error and raises if JSON is invalid' do - assert_raises(JSON::ParserError) do - Reporting::ReportJob.new.perform(nil, 'invalid json') - end - end - end -end diff --git a/rails-app/test/mailers/.keep b/rails-app/test/mailers/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/rails-app/test/models/.keep b/rails-app/test/models/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/rails-app/test/models/veteran/disability_rating_request_test.rb b/rails-app/test/models/veteran/disability_rating_request_test.rb deleted file mode 100644 index 8de7646..0000000 --- a/rails-app/test/models/veteran/disability_rating_request_test.rb +++ /dev/null @@ -1,67 +0,0 @@ -require 'test_helper' - -module Veteran - class DisabilityRatingRequestTest < ActiveSupport::TestCase - test 'to_va_payload maps fields correctly' do - params = { - firstName: 'John', - middleName: 'M', - lastName: 'Doe', - dateOfBirth: '1990-01-01', - ssn: '999999999', - address: { - street1: '123 Main St', - city: 'Anytown', - state: 'NY', - postalCode: '12345', - country: 'USA' - } - } - req = DisabilityRatingRequest.new(params) - payload = req.to_va_payload - - assert_equal 'John', payload[:first_name] - assert_equal 'M', payload[:middle_name] - assert_equal 'Doe', payload[:last_name] - assert_equal '1990-01-01', payload[:birth_date] - assert_equal '999999999', payload[:ssn] - assert_equal '123 Main St', payload[:street_address_line1] - assert_equal 'Anytown', payload[:city] - assert_equal 'NY', payload[:state] - assert_equal '12345', payload[:zipcode] - assert_equal 'USA', payload[:country] - end - - test 'to_va_payload omits optional fields' do - params = { - firstName: 'John', - lastName: 'Doe', - dateOfBirth: '1990-01-01' - } - req = DisabilityRatingRequest.new(params) - payload = req.to_va_payload - - assert_equal 'John', payload[:first_name] - assert_equal 'Doe', payload[:last_name] - assert_equal '1990-01-01', payload[:birth_date] - assert_nil payload[:middle_name] - assert_nil payload[:ssn] - assert_nil payload[:street_address_line1] - end - - test 'can_use_restricted_endpoint? returns true when ssn is present' do - req = DisabilityRatingRequest.new(ssn: '999999999') - assert req.can_use_restricted_endpoint? - end - - test 'can_use_restricted_endpoint? returns false when ssn is absent' do - req = DisabilityRatingRequest.new - assert_not req.can_use_restricted_endpoint? - end - - test 'can_use_restricted_endpoint? returns false when ssn is empty' do - req = DisabilityRatingRequest.new(ssn: '') - assert_not req.can_use_restricted_endpoint? - end - end -end diff --git a/rails-app/test/requests/health_test.rb b/rails-app/test/requests/health_test.rb deleted file mode 100644 index 158e567..0000000 --- a/rails-app/test/requests/health_test.rb +++ /dev/null @@ -1,34 +0,0 @@ -require "test_helper" - -class HealthTest < ActionDispatch::IntegrationTest - test "should get health" do - # Mock Redis to ensure it doesn't try to connect to a real server during tests - # if it's not available, although localhost:6379 might be available. - # To be safe and deterministic: - redis_mock = Minitest::Mock.new - redis_mock.expect :ping, "PONG" - redis_mock.expect :close, nil - - Redis.stub :new, redis_mock do - get "/health" - assert_response :success - end - - redis_mock.verify - end - - test "should return service_unavailable when redis is down" do - redis_mock = Minitest::Mock.new - redis_mock.expect :ping, nil do - raise Redis::CannotConnectError, "Error connecting to Redis" - end - redis_mock.expect :close, nil - - Redis.stub :new, redis_mock do - get "/health" - assert_response :service_unavailable - end - - redis_mock.verify - end -end diff --git a/rails-app/test/services/education/nsc_client_test.rb b/rails-app/test/services/education/nsc_client_test.rb deleted file mode 100644 index d053f50..0000000 --- a/rails-app/test/services/education/nsc_client_test.rb +++ /dev/null @@ -1,161 +0,0 @@ -require 'test_helper' -require 'minitest/mock' -require 'net/http' -require 'stoplight' - -module Education - class NscClientTest < ActiveSupport::TestCase - setup do - @env_overrides = { - 'NSC_SUBMIT_URL' => 'https://example.test/submit', - 'NSC_ACCOUNT_ID' => '12345', - 'NSC_CLIENT_ID' => 'client-id', - 'NSC_CLIENT_SECRET' => 'client-secret', - 'NSC_TOKEN_URL' => 'https://example.test/token' - } - @original_env = @env_overrides.keys.each_with_object({}) { |k, h| h[k] = ENV[k] } - @env_overrides.each { |k, v| ENV[k] = v } - - @client = NscClient.new - @coordinator = ServiceCoordinator.new - @req_body = { - firstName: 'Lynette', - lastName: 'Oyola', - dateOfBirth: '1988-10-24' - } - end - - teardown do - @original_env.each { |k, v| ENV[k] = v } - end - - private - - def stub_nsc_requests(oauth_resp, submit_resp) - http_mock_oauth = Minitest::Mock.new - http_mock_oauth.expect :use_ssl=, true, [true] - http_mock_oauth.expect :request, oauth_resp, [Net::HTTP::Post] - - http_mock_submit = Minitest::Mock.new - http_mock_submit.expect :use_ssl=, true, [true] - http_mock_submit.expect :request, submit_resp, [Net::HTTP::Post] - - # We need to stub Net::HTTP.new to return different mocks for different calls. - # A simple way with Minitest::Mock is to expect :new and return mocks in order. - # But Net::HTTP.stub :new stubs the class method. - - calls = 0 - Net::HTTP.stub :new, proc { |host, port| - calls += 1 - calls == 1 ? http_mock_oauth : http_mock_submit - } do - yield - end - - http_mock_oauth.verify - http_mock_submit.verify - end - - public - - test 'lookup_enrollment_status success with positive hit' do - oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - oauth_response.instance_variable_set(:@read, true) - oauth_response.instance_variable_set(:@body, { access_token: 'fake-token' }.to_json) - - submit_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - submit_response.instance_variable_set(:@read, true) - submit_response.instance_variable_set(:@body, { - status: { code: '0', message: 'Successful', severity: 'Info' }, - transactionDetails: { nscHit: 'Y', transactionStatus: 'CNF' }, - enrollmentDetails: [{ currentEnrollmentStatus: 'CC' }] - }.to_json) - - stub_nsc_requests(oauth_response, submit_response) do - result = @client.lookup_enrollment_status(@req_body).as_json - - assert_equal 'ENROLLMENT_STATUS_UNKNOWN_CREDIT_TIMING', result[:enrollmentStatus] - assert_equal 'NSC', result[:dataSource] - assert_not_nil result[:metadata] - end - end - - test 'lookup_enrollment_status maps specific enrollment status' do - oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - oauth_response.instance_variable_set(:@read, true) - oauth_response.instance_variable_set(:@body, { access_token: 'fake-token' }.to_json) - - submit_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - submit_response.instance_variable_set(:@read, true) - submit_response.instance_variable_set(:@body, { - transactionDetails: { nscHit: 'Y' }, - enrollmentDetails: [{ - currentEnrollmentStatus: 'CC', - officialSchoolName: 'University A', - enrollmentData: [{ enrollmentStatus: 'H', termBeginDate: '2023-01-01', termEndDate: '2023-05-01' }] - }] - }.to_json) - - stub_nsc_requests(oauth_response, submit_response) do - result = @client.lookup_enrollment_status(@req_body).as_json - - assert_equal 'HALF_TIME', result[:enrollmentStatus] - assert_equal 1, result[:enrollmentDetails].size - assert_equal 'HALF_TIME', result[:enrollmentDetails][0][:enrollmentStatus] - assert_equal 'University A', result[:enrollmentDetails][0][:schoolName] - end - end - - test 'lookup_enrollment_status raises Not Found for no hit' do - oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - oauth_response.instance_variable_set(:@read, true) - oauth_response.instance_variable_set(:@body, { access_token: 'fake-token' }.to_json) - - submit_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - submit_response.instance_variable_set(:@read, true) - submit_response.instance_variable_set(:@body, { - transactionDetails: { nscHit: 'N' }, - enrollmentDetails: [{ currentEnrollmentStatus: 'CN' }] - }.to_json) - - stub_nsc_requests(oauth_response, submit_response) do - assert_raises(Education::NotFoundError) do - @client.lookup_enrollment_status(@req_body) - end - end - end - - test 'lookup_enrollment_status raises Not Found for currently not enrolled (CN)' do - oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - oauth_response.instance_variable_set(:@read, true) - oauth_response.instance_variable_set(:@body, { access_token: 'fake-token' }.to_json) - - submit_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - submit_response.instance_variable_set(:@read, true) - submit_response.instance_variable_set(:@body, { - transactionDetails: { nscHit: 'Y' }, - enrollmentDetails: [{ currentEnrollmentStatus: 'CN' }] - }.to_json) - - stub_nsc_requests(oauth_response, submit_response) do - assert_raises(Education::NotFoundError) do - @client.lookup_enrollment_status(@req_body) - end - end - end - - test 'lookup_enrollment_status raises error for non-2xx response' do - oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - oauth_response.instance_variable_set(:@read, true) - oauth_response.instance_variable_set(:@body, { access_token: 'fake-token' }.to_json) - - submit_response = Net::HTTPBadGateway.new('1.1', '502', 'Bad Gateway') - - stub_nsc_requests(oauth_response, submit_response) do - assert_raises(StandardError, /NSC submit failed: status=502/) do - @client.lookup_enrollment_status(@req_body) - end - end - end - end -end diff --git a/rails-app/test/services/veteran/va_client_test.rb b/rails-app/test/services/veteran/va_client_test.rb deleted file mode 100644 index b96f1ab..0000000 --- a/rails-app/test/services/veteran/va_client_test.rb +++ /dev/null @@ -1,178 +0,0 @@ -require 'test_helper' -require 'minitest/mock' -require 'net/http' -require 'stoplight' - -module Veteran - class VaClientTest < ActiveSupport::TestCase - setup do - @env_overrides = { - 'VA_BASE_URL' => 'https://example.test/va', - 'VA_TOKEN_URL' => 'https://example.test/token', - 'VA_CLIENT_ID' => 'client-id', - 'VA_AUD' => 'https://example.test/aud', - 'VA_PRIVATE_KEY_PATH' => 'test/fixtures/files/test.key', - 'SERVICE_VERSION' => '1.3.0', - 'ENVIRONMENT' => 'test' - } - @original_env = @env_overrides.keys.each_with_object({}) { |k, h| h[k] = ENV[k] } - @env_overrides.each { |k, v| ENV[k] = v } - - # Create a dummy private key for testing - @key = OpenSSL::PKey::RSA.new(2048) - FileUtils.mkdir_p('test/fixtures/files') - File.write('test/fixtures/files/test.key', @key.to_pem) - - @client = VaClient.new - @coordinator = ServiceCoordinator.new - @req_params = { - firstName: 'Lynette', - lastName: 'Oyola', - dateOfBirth: '1988-10-24', - ssn: '123456789' - } - Current.request_id = 'test-request-id' - end - - teardown do - File.delete('test/fixtures/files/test.key') if File.exist?('test/fixtures/files/test.key') - @original_env.each { |k, v| ENV[k] = v } - end - - private - - def stub_va_requests(oauth_resp, va_total_disability_resp = nil, rating_path: nil, total_disability_path: nil) - http_mock_oauth = Minitest::Mock.new - http_mock_oauth.expect :use_ssl=, true, [true] - http_mock_oauth.expect :request, oauth_resp, [Net::HTTP::Post] - - http_mock_va = Minitest::Mock.new - http_mock_va.expect :use_ssl=, true, [true] - - if va_total_disability_resp - total_disability_matcher = ->(req) { - req.is_a?(Net::HTTP::Post) && (total_disability_path.nil? || req.path == total_disability_path) - } - http_mock_va.expect :request, va_total_disability_resp do |req| - total_disability_matcher.call(req) - end - end - - calls = 0 - Net::HTTP.stub :new, proc { |host, port| - calls += 1 - calls == 1 ? http_mock_oauth : http_mock_va - } do - yield - end - - http_mock_oauth.verify - http_mock_va.verify - end - - public - - test 'lookup_disability_rating success uses restricted endpoint when SSN is present' do - oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - oauth_response.instance_variable_set(:@read, true) - oauth_response.instance_variable_set(:@body, { access_token: 'fake-token' }.to_json) - - va_rating_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - va_rating_response.instance_variable_set(:@read, true) - va_rating_response.instance_variable_set(:@body, { - data: { - attributes: { - combined_disability_rating: 100, - combined_effective_date: '2023-01-01', - legal_effective_date: '2023-01-01', - individual_ratings: [ - { rating_end_date: '2024-01-01' }, - { rating_end_date: '2023-12-01' } - ] - } - } - }.to_json) - - va_total_disability_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - va_total_disability_response.instance_variable_set(:@read, true) - va_total_disability_response.instance_variable_set(:@body, { - data: { - total_disability: { - status: true, - effective_date: "2023-01-01" - }, - permanent_and_total: { - service_connected_status: false, - pension_award_status: false - } - } - }.to_json) - - stub_va_requests(oauth_response, va_total_disability_response, - rating_path: '/va/restricted/disability_rating', - total_disability_path: '/va/restricted/permanent_and_total_disability') do - response = @client.lookup_disability_rating(@req_params) - assert_equal true, response.total_disability_status - assert_equal true, response.total_disability_status_effective_date.present? - assert_equal 'test-request-id', response.metadata[:transactionId] - end - end - - test 'lookup_disability_rating success uses standard endpoint when SSN is absent' do - params_without_ssn = @req_params.except(:ssn) - - oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - oauth_response.instance_variable_set(:@read, true) - oauth_response.instance_variable_set(:@body, { access_token: 'fake-token' }.to_json) - - va_rating_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - va_rating_response.instance_variable_set(:@read, true) - va_rating_response.instance_variable_set(:@body, { - data: { - attributes: { - combined_disability_rating: 70, - combined_effective_date: '2023-01-01', - legal_effective_date: '2023-01-01' - } - } - }.to_json) - - va_total_disability_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - va_total_disability_response.instance_variable_set(:@read, true) - va_total_disability_response.instance_variable_set(:@body, { - data: { - total_disability: { - status: true, - effective_date: "2023-01-01" - }, - permanent_and_total: { - service_connected_status: true, - pension_award_status: true - } - } - }.to_json) - - stub_va_requests(oauth_response, va_total_disability_response, - rating_path: '/va/disability_rating', - total_disability_path: '/va/permanent_and_total_disability') do - response = @client.lookup_disability_rating(params_without_ssn) - assert_equal true, response.total_disability_status - assert_equal true, response.total_disability_status_effective_date.present? - end - end - - test 'lookup_disability_rating raises NotFoundError on 404' do - oauth_response = Net::HTTPSuccess.new('1.1', '200', 'OK') - oauth_response.instance_variable_set(:@read, true) - oauth_response.instance_variable_set(:@body, { access_token: 'fake-token' }.to_json) - - va_response = Net::HTTPNotFound.new('1.1', '404', 'Not Found') - - stub_va_requests(oauth_response, va_response) do - assert_raises(Veteran::NotFoundError) do - @client.lookup_disability_rating(@req_params) - end - end - end - end -end diff --git a/rails-app/test/test_helper.rb b/rails-app/test/test_helper.rb deleted file mode 100644 index 62f75b9..0000000 --- a/rails-app/test/test_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -ENV["RAILS_ENV"] ||= "test" -require_relative "../config/environment" -require "rails/test_help" - -module ActiveSupport - class TestCase - # Run tests in parallel with specified workers - parallelize(workers: :number_of_processors) - - # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. - # fixtures :all - - # Add more helper methods to be used by all tests here... - end -end