diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 54bc5971e14..d81901f0987 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -241,6 +241,58 @@ stages: export OKTA_OFFICE_GROUP_ID=notrealgroupId export OKTA_CUSTOMER_GROUP_ID=notrealcustomergroupId +# .setup_host_intergration_tests: &setup_host_intergration_tests +# - echo "Setting up /etc/hosts for local domain simulation" +# - echo "127.0.0.1 milmovelocal" | sudo tee -a /etc/hosts +# - echo "127.0.0.1 officelocal" | sudo tee -a /etc/hosts +# - echo "127.0.0.1 adminlocal" | sudo tee -a /etc/hosts +# - echo "127.0.0.1 primelocal" | sudo tee -a /etc/hosts + +.setup_env_intergration_mtls: &setup_env_intergration_mtls + - | + echo "Setting up environment variables" + export MIL_MOVE_DOD_CA_CERT=$(cat config/tls/devlocal-ca.pem) + export MIL_MOVE_DOD_TLS_CERT=$(cat config/tls/devlocal-https.pem) + export MIL_MOVE_DOD_TLS_KEY=$(cat config/tls/devlocal-https.key) + export CLIENT_AUTH_SECRET_KEY=$(cat config/tls/devlocal-client_auth_secret.key) + export LOGIN_GOV_SECRET_KEY=$(echo $E2E_LOGIN_GOV_SECRET_KEY | base64 --decode) + export HERE_MAPS_APP_ID=$E2E_HERE_MAPS_APP_ID + export HERE_MAPS_APP_CODE=$E2E_HERE_MAPS_APP_CODE + echo "Overriding application-specific configurations" + sed 's,^,export ,' config/env/review.app.env > server_env + source server_env + export HERE_MAPS_GEOCODE_ENDPOINT=https://geocoder.api.here.com/6.2/geocode.json + export HERE_MAPS_ROUTING_ENDPOINT=https://route.api.here.com/routing/7.2/calculateroute.json + export LOGIN_GOV_CALLBACK_PORT=4000 + export LOGIN_GOV_CALLBACK_PROTOCOL=http + make db_dev_create + bin/milmove migrate + mkdir -p build + touch build/index.html + bin/milmove serve 2>&1 | tee server.log & + +.e2e_tests_playwright: &e2e_tests_playwright + - | + echo "Preparing the environment" + export MIL_MOVE_DOD_CA_CERT=$(cat config/tls/devlocal-ca.pem) + export MIL_MOVE_DOD_TLS_CERT=$(cat config/tls/devlocal-https.pem) + export MIL_MOVE_DOD_TLS_KEY=$(cat config/tls/devlocal-https.key) + export CLIENT_AUTH_SECRET_KEY=$(cat config/tls/devlocal-client_auth_secret.key) + export LOGIN_GOV_SECRET_KEY=$(echo $E2E_LOGIN_GOV_SECRET_KEY | base64 --decode) + export HERE_MAPS_APP_ID=$E2E_HERE_MAPS_APP_ID + export HERE_MAPS_APP_CODE=$E2E_HERE_MAPS_APP_CODE + sed 's,^,export ,' config/env/review.app.env > server_env + source server_env + make db_dev_create + bin/milmove migrate + bin/milmove serve & + echo "Waiting for server to start" + dockerize -wait http://milmovelocal:4000 -timeout 5m + echo "Installing Playwright dependencies" + yarn install --frozen-lockfile --cache-folder ~/.cache/yarn + ./node_modules/.bin/playwright install + + sast: stage: pre_checks tags: @@ -832,15 +884,49 @@ integration_tests: integration_test_mtls: stage: test tags: - - $RUNNER_TAG + - $DOCKER_RUNNER_TAG image: $DOCKER_APP_IMAGE + services: + - name: docker:dind + alias: docker + - name: $postgres + - name: $redis + variables: + DOCKER_HOST: "tcp://docker-backend.gitlab-runner.svc.cluster.local:2375" + DOCKER_TLS_CERTDIR: "" + APPLICATION: app + DB_PASSWORD: mysecretpassword + DB_USER_LOW_PRIV: crud + DB_PASSWORD_LOW_PRIV: mysecretpassword + DB_USER: postgres + DB_HOST: localhost + DB_PORT: 5432 + DB_NAME: dev_db + DB_NAME_DEV: dev_db + MIGRATION_MANIFEST: '/builds/milmove/mymove/migrations/app/migrations_manifest.txt' + MIGRATION_PATH: 'file:///builds/milmove/mymove/migrations/app/schema;file:///builds/milmove/mymove/migrations/app/secure' + EIA_KEY: db2522a43820268a41a802a16ae9fd26 # dummy key generated with openssl rand -hex 16 + ENVIRONMENT: development + DOD_CA_PACKAGE: /builds/milmove/mymove/config/tls/milmove-cert-bundle.p7b + POSTGRES_PASSWORD: mysecretpassword + POSTGRES_DB: test_db needs: - pre_deps_yarn - compile_app_server - before_script: *setup_milmove_env + before_script: + - *setup_milmove_env + - *setup_env_intergration_mtls script: - echo "TODO Add steps" - echo "integration_test_mtls" + - echo "Waiting for server to start" + - dockerize -wait http://milmovelocal:4000 -timeout 5m + - echo "Running E2E mTLS tests" + - ./scripts/run-e2e-mtls-test + artifacts: + paths: + - test-results/ + when: always allow_failure: true after_script: - *announce_failure @@ -850,17 +936,55 @@ integration_test_mtls: integration_test_admin: stage: test tags: - - $RUNNER_TAG + - $DOCKER_RUNNER_TAG image: $DOCKER_APP_IMAGE + services: + - name: docker:dind + alias: docker + - name: $postgres + - name: $redis + variables: + DOCKER_HOST: "tcp://docker-backend.gitlab-runner.svc.cluster.local:2375" + DOCKER_TLS_CERTDIR: "" + APPLICATION: app + DB_PASSWORD: mysecretpassword + DB_USER_LOW_PRIV: crud + DB_PASSWORD_LOW_PRIV: mysecretpassword + DB_USER: postgres + DB_HOST: localhost + DB_PORT: 5432 + DB_NAME: dev_db + DB_NAME_DEV: dev_db + MIGRATION_MANIFEST: '/builds/milmove/mymove/migrations/app/migrations_manifest.txt' + MIGRATION_PATH: 'file:///builds/milmove/mymove/migrations/app/schema;file:///builds/milmove/mymove/migrations/app/secure' + EIA_KEY: db2522a43820268a41a802a16ae9fd26 # dummy key generated with openssl rand -hex 16 + ENVIRONMENT: development + DOD_CA_PACKAGE: /builds/milmove/mymove/config/tls/milmove-cert-bundle.p7b + POSTGRES_PASSWORD: mysecretpassword + POSTGRES_DB: test_db needs: - pre_deps_yarn - pre_deps_golang - compile_app_client - compile_app_server - before_script: *setup_milmove_env + before_script: + - *setup_milmove_env + - *e2e_tests_playwright script: - echo "TODO Add steps" - echo "integration_test_admin" + - echo "Running integration tests for Admin" + - ./node_modules/.bin/playwright test playwright/tests/admin \ + --reporter=html,junit \ + --trace=on \ + --workers=1 + artifacts: + paths: + - playwright-report/ + - complete-playwright-report.zip + - playwright-results.xml + when: always + allow_failure: true after_script: - *announce_failure rules: @@ -869,17 +993,55 @@ integration_test_admin: integration_test_my: stage: test tags: - - $RUNNER_TAG + - $DOCKER_RUNNER_TAG image: $DOCKER_APP_IMAGE + services: + - name: docker:dind + alias: docker + - name: $postgres + - name: $redis + variables: + DOCKER_HOST: "tcp://docker-backend.gitlab-runner.svc.cluster.local:2375" + DOCKER_TLS_CERTDIR: "" + APPLICATION: app + DB_PASSWORD: mysecretpassword + DB_USER_LOW_PRIV: crud + DB_PASSWORD_LOW_PRIV: mysecretpassword + DB_USER: postgres + DB_HOST: localhost + DB_PORT: 5432 + DB_NAME: dev_db + DB_NAME_DEV: dev_db + MIGRATION_MANIFEST: '/builds/milmove/mymove/migrations/app/migrations_manifest.txt' + MIGRATION_PATH: 'file:///builds/milmove/mymove/migrations/app/schema;file:///builds/milmove/mymove/migrations/app/secure' + EIA_KEY: db2522a43820268a41a802a16ae9fd26 # dummy key generated with openssl rand -hex 16 + ENVIRONMENT: development + DOD_CA_PACKAGE: /builds/milmove/mymove/config/tls/milmove-cert-bundle.p7b + POSTGRES_PASSWORD: mysecretpassword + POSTGRES_DB: test_db needs: - pre_deps_yarn - pre_deps_golang - compile_app_client - compile_app_server - before_script: *setup_milmove_env + before_script: + - *setup_milmove_env + - *e2e_tests_playwright script: - echo "TODO Add steps" - echo "integration_test_my" + - echo "Running integration tests for My" + - ./node_modules/.bin/playwright test playwright/tests/my \ + --reporter=html,junit \ + --trace=on \ + --workers=1 \ + --shard="$CI_NODE_INDEX/$CI_NODE_TOTAL" + artifacts: + paths: + - playwright-report/ + - complete-playwright-report.zip + - playwright-results.xml + when: always allow_failure: true after_script: - *announce_failure @@ -889,17 +1051,53 @@ integration_test_my: integration_test_office: stage: test tags: - - $RUNNER_TAG + - $DOCKER_RUNNER_TAG image: $DOCKER_APP_IMAGE + services: + - name: docker:dind + alias: docker + - name: $postgres + - name: $redis + variables: + DOCKER_HOST: "tcp://docker-backend.gitlab-runner.svc.cluster.local:2375" + DOCKER_TLS_CERTDIR: "" + APPLICATION: app + DB_PASSWORD: mysecretpassword + DB_USER_LOW_PRIV: crud + DB_PASSWORD_LOW_PRIV: mysecretpassword + DB_USER: postgres + DB_HOST: localhost + DB_PORT: 5432 + DB_NAME: dev_db + DB_NAME_DEV: dev_db + MIGRATION_MANIFEST: '/builds/milmove/mymove/migrations/app/migrations_manifest.txt' + MIGRATION_PATH: 'file:///builds/milmove/mymove/migrations/app/schema;file:///builds/milmove/mymove/migrations/app/secure' + EIA_KEY: db2522a43820268a41a802a16ae9fd26 # dummy key generated with openssl rand -hex 16 + ENVIRONMENT: development + DOD_CA_PACKAGE: /builds/milmove/mymove/config/tls/milmove-cert-bundle.p7b + POSTGRES_PASSWORD: mysecretpassword + POSTGRES_DB: test_db needs: - pre_deps_yarn - pre_deps_golang - compile_app_client - compile_app_server - before_script: *setup_milmove_env + before_script: + - *setup_milmove_env + - *e2e_tests_playwright script: - echo "TODO Add steps" - echo "integration_test_office" + - ./node_modules/.bin/playwright test playwright/tests/office \ + --reporter=html,junit \ + --trace=on \ + --workers=1 + artifacts: + paths: + - playwright-report/ + - complete-playwright-report.zip + - playwright-results.xml + when: always allow_failure: true after_script: - *announce_failure @@ -914,7 +1112,7 @@ build_push_app_dp3: stage: push tags: - $RUNNER_TAG - environment: DP3_ENV + environment: $DP3_ENV image: name: gcr.io/kaniko-project/executor:v1.14.0-debug entrypoint: [""] @@ -937,7 +1135,7 @@ build_push_migrations_dp3: stage: push tags: - $RUNNER_TAG - environment: DP3_ENV + environment: $DP3_ENV image: name: gcr.io/kaniko-project/executor:v1.14.0-debug entrypoint: [""] @@ -960,7 +1158,7 @@ build_push_tasks_dp3: stage: push tags: - $RUNNER_TAG - environment: DP3_ENV + environment: $DP3_ENV image: name: gcr.io/kaniko-project/executor:v1.14.0-debug entrypoint: [""] @@ -983,7 +1181,7 @@ push_otel_collector_image_dp3: stage: push tags: - $RUNNER_TAG - environment: DP3_ENV + environment: $DP3_ENV before_script: - *setup_aws_vars_dp3 - *setup_release_dp3 @@ -1015,7 +1213,7 @@ deploy_migrations_dp3: stage: deploy tags: - $RUNNER_TAG - environment: DP3_ENV + environment: $DP3_ENV image: name: $DOCKER_APP_IMAGE entrypoint: [""] @@ -1046,6 +1244,7 @@ deploy_tasks_dp3: stage: deploy tags: - $RUNNER_TAG + environment: $DP3_ENV image: name: $DOCKER_APP_IMAGE entrypoint: [""] @@ -1073,7 +1272,7 @@ deploy_app_client_tls_dp3: stage: deploy tags: - $RUNNER_TAG - environment: DP3_ENV + environment: $DP3_ENV image: name: $DOCKER_APP_IMAGE entrypoint: [""] @@ -1087,6 +1286,7 @@ deploy_app_client_tls_dp3: HEALTH_CHECK: "true" before_script: - *setup_aws_vars_dp3 + - *setup_tls_vars_dp3 - *setup_release_dp3 script: # - echo "Comparing against deployed commit" @@ -1099,11 +1299,11 @@ deploy_app_client_tls_dp3: - echo "Deploying app-client-tls service" - ./scripts/ecs-deploy-service-container app-client-tls "${ECR_REPOSITORY_URI}/app@${ECR_DIGEST}" "${APP_ENVIRONMENT}" "/bin/milmove serve" - echo "Running Health Check" - # - bin/health-checker --schemes https --hosts api.demo.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --tries 10 --backoff 3 --log-level info --timeout 5m - # - echo "Running TLS Check" - # - bin/tls-checker --schemes https --hosts api.demo.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --log-level info --timeout 15m - # - echo "Checking deployed commits" - # - ./scripts/check-deployed-commit "api.demo.dp3.us" "$CI_COMMIT_SHA" ${TLS_KEY} ${TLS_CERT} ${TLS_CA} + - bin/health-checker --schemes https --hosts api.$APP_ENVIRONMENT.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --tries 10 --backoff 3 --log-level info --timeout 5m + - echo "Running TLS Check" + - bin/tls-checker --schemes https --hosts api.$APP_ENVIRONMENT.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --log-level info --timeout 15m + - echo "Checking deployed commits" + - ./scripts/check-deployed-commit "api.$APP_ENVIRONMENT.dp3.us" "$CI_COMMIT_SHA" ${TLS_KEY} ${TLS_CERT} ${TLS_CA} after_script: - *announce_failure rules: @@ -1113,7 +1313,7 @@ deploy_app_dp3: stage: deploy tags: - $RUNNER_TAG - environment: DP3_ENV + environment: $DP3_ENV image: name: $DOCKER_APP_IMAGE entrypoint: [""] @@ -1126,6 +1326,7 @@ deploy_app_dp3: OPEN_TELEMETRY_SIDECAR: "true" HEALTH_CHECK: "true" before_script: + - *setup_tls_vars_dp3 - *setup_aws_vars_dp3 - *setup_release_dp3 script: @@ -1148,11 +1349,11 @@ deploy_app_dp3: - echo "Deploying app service" - ./scripts/ecs-deploy-service-container app "${ECR_REPOSITORY_URI}/app@${ECR_DIGEST}" "${APP_ENVIRONMENT}" "/bin/milmove serve" - echo "Running Health Check" - # - bin/health-checker --schemes https --hosts my.demo.dp3.us,office.demo.dp3.us,admin.demo.dp3.us --tries 10 --backoff 3 --log-level info --timeout 5m - # - echo "Running TLS Check" - # - bin/tls-checker --schemes https --hosts my.demo.dp3.us,office.demo.dp3.us,admin.demo.dp3.us --log-level info --timeout 15m - # - echo "Checking deployed commits" - - ./scripts/check-deployed-commit "my.demo.dp3.us,office.demo.dp3.us,admin.demo.dp3.us" "$CI_COMMIT_SHA" + - bin/health-checker --schemes https --hosts my.$DP3_ENV.dp3.us,office.$DP3_ENV.dp3.us,admin.$DP3_ENV.dp3.us --tries 10 --backoff 3 --log-level info --timeout 5m + - echo "Running TLS Check" + - bin/tls-checker --schemes https --hosts my.$DP3_ENV.dp3.us,office.$DP3_ENV.dp3.us,admin.$DP3_ENV.dp3.us --log-level info --timeout 15m + - echo "Checking deployed commits" + - ./scripts/check-deployed-commit "my.$DP3_ENV.dp3.us,office.$DP3_ENV.dp3.us,admin.$DP3_ENV.dp3.us" "$CI_COMMIT_SHA" after_script: - *announce_failure rules: @@ -1337,6 +1538,7 @@ deploy_app_client_tls_stg: OPEN_TELEMETRY_SIDECAR: "true" HEALTH_CHECK: "true" before_script: + - *setup_tls_vars_stg - *setup_aws_vars_stg - *setup_release_stg script: @@ -1349,12 +1551,13 @@ deploy_app_client_tls_stg: - export OTEL_COLLECTOR_IMAGE="${ECR_REPOSITORY_URI}/otel-collector@${OTEL_ECR_DIGEST}" - echo "Deploying app-client-tls service" - ./scripts/ecs-deploy-service-container app-client-tls "${ECR_REPOSITORY_URI}/app@${ECR_DIGEST}" "${APP_ENVIRONMENT}" "/bin/milmove serve" + #TODO: fix domain make dynamic and pass in preferred - echo "Running Health Check" - # - bin/health-checker --schemes https --hosts api.demo.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --tries 10 --backoff 3 --log-level info --timeout 5m - # - echo "Running TLS Check" - # - bin/tls-checker --schemes https --hosts api.demo.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --log-level info --timeout 15m - # - echo "Checking deployed commits" - # - ./scripts/check-deployed-commit "api.demo.dp3.us" "$CI_COMMIT_SHA" ${TLS_KEY} ${TLS_CERT} ${TLS_CA} + - bin/health-checker --schemes https --hosts api.$APP_ENVIRONMENT.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --tries 10 --backoff 3 --log-level info --timeout 5m + - echo "Running TLS Check" + - bin/tls-checker --schemes https --hosts api.$APP_ENVIRONMENT.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --log-level info --timeout 15m + - echo "Checking deployed commits" + - ./scripts/check-deployed-commit "api.$APP_ENVIRONMENT.dp3.us" "$CI_COMMIT_SHA" ${TLS_KEY} ${TLS_CERT} ${TLS_CA} after_script: - *announce_failure rules: @@ -1377,6 +1580,7 @@ deploy_app_stg: OPEN_TELEMETRY_SIDECAR: "true" HEALTH_CHECK: "true" before_script: + - *setup_tls_vars_stg - *setup_aws_vars_stg - *setup_release_stg script: @@ -1398,12 +1602,13 @@ deploy_app_stg: - export OTEL_COLLECTOR_IMAGE="${ECR_REPOSITORY_URI}/otel-collector@${OTEL_ECR_DIGEST}" - echo "Deploying app service" - ./scripts/ecs-deploy-service-container app "${ECR_REPOSITORY_URI}/app@${ECR_DIGEST}" "${APP_ENVIRONMENT}" "/bin/milmove serve" + #TODO: fix domain make dynamic and pass in preferred - echo "Running Health Check" - # - bin/health-checker --schemes https --hosts my.demo.dp3.us,office.demo.dp3.us,admin.demo.dp3.us --tries 10 --backoff 3 --log-level info --timeout 5m - # - echo "Running TLS Check" - # - bin/tls-checker --schemes https --hosts my.demo.dp3.us,office.demo.dp3.us,admin.demo.dp3.us --log-level info --timeout 15m - # - echo "Checking deployed commits" - - ./scripts/check-deployed-commit "my.demo.dp3.us,office.demo.dp3.us,admin.demo.dp3.us" "$CI_COMMIT_SHA" + - bin/health-checker --schemes https --hosts my.$APP_ENVIRONMENT.dp3.us,office.$APP_ENVIRONMENT.dp3.us,admin.$APP_ENVIRONMENT.dp3.us --tries 10 --backoff 3 --log-level info --timeout 5m + - echo "Running TLS Check" + - bin/tls-checker --schemes https --hosts my.$APP_ENVIRONMENT.dp3.us,office.$APP_ENVIRONMENT.dp3.us,admin.$APP_ENVIRONMENT.dp3.us --log-level info --timeout 15m + - echo "Checking deployed commits" + - ./scripts/check-deployed-commit "my.$APP_ENVIRONMENT.dp3.us,office.$APP_ENVIRONMENT.dp3.us,admin.$APP_ENVIRONMENT.dp3.us" "$CI_COMMIT_SHA" after_script: - *announce_failure rules: @@ -1608,6 +1813,7 @@ deploy_app_client_tls_prd: OPEN_TELEMETRY_SIDECAR: "true" HEALTH_CHECK: "true" before_script: + - *setup_tls_vars_prd - *setup_aws_vars_prd - *setup_release_prd script: @@ -1620,12 +1826,13 @@ deploy_app_client_tls_prd: - export OTEL_COLLECTOR_IMAGE="${ECR_REPOSITORY_URI}/otel-collector@${OTEL_ECR_DIGEST}" - echo "Deploying app-client-tls service" - ./scripts/ecs-deploy-service-container app-client-tls "${ECR_REPOSITORY_URI}/app@${ECR_DIGEST}" "${APP_ENVIRONMENT}" "/bin/milmove serve" + #TODO: fix domain make dynamic and pass in preferred - echo "Running Health Check" - # - bin/health-checker --schemes https --hosts api.demo.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --tries 10 --backoff 3 --log-level info --timeout 5m - # - echo "Running TLS Check" - # - bin/tls-checker --schemes https --hosts api.demo.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --log-level info --timeout 15m - # - echo "Checking deployed commits" - # - ./scripts/check-deployed-commit "api.demo.dp3.us" "$CI_COMMIT_SHA" ${TLS_KEY} ${TLS_CERT} ${TLS_CA} + - bin/health-checker --schemes https --hosts api.$APP_ENVIRONMENT.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --tries 10 --backoff 3 --log-level info --timeout 5m + - echo "Running TLS Check" + - bin/tls-checker --schemes https --hosts api.$APP_ENVIRONMENT.dp3.us --key ${TLS_KEY} --cert ${TLS_CERT} --ca ${TLS_CA} --log-level info --timeout 15m + - echo "Checking deployed commits" + - ./scripts/check-deployed-commit "api.$APP_ENVIRONMENT.dp3.us" "$CI_COMMIT_SHA" ${TLS_KEY} ${TLS_CERT} ${TLS_CA} after_script: - *announce_failure rules: @@ -1648,6 +1855,7 @@ deploy_app_prd: OPEN_TELEMETRY_SIDECAR: "true" HEALTH_CHECK: "true" before_script: + - *setup_tls_vars_prd - *setup_aws_vars_prd - *setup_release_prd script: @@ -1669,12 +1877,13 @@ deploy_app_prd: - export OTEL_COLLECTOR_IMAGE="${ECR_REPOSITORY_URI}/otel-collector@${OTEL_ECR_DIGEST}" - echo "Deploying app service" - ./scripts/ecs-deploy-service-container app "${ECR_REPOSITORY_URI}/app@${ECR_DIGEST}" "${APP_ENVIRONMENT}" "/bin/milmove serve" + #TODO: fix domain make dynamic and pass in preferred - echo "Running Health Check" - # - bin/health-checker --schemes https --hosts my.demo.dp3.us,office.demo.dp3.us,admin.demo.dp3.us --tries 10 --backoff 3 --log-level info --timeout 5m - # - echo "Running TLS Check" - # - bin/tls-checker --schemes https --hosts my.demo.dp3.us,office.demo.dp3.us,admin.demo.dp3.us --log-level info --timeout 15m - # - echo "Checking deployed commits" - - ./scripts/check-deployed-commit "my.demo.dp3.us,office.demo.dp3.us,admin.demo.dp3.us" "$CI_COMMIT_SHA" + - bin/health-checker --schemes https --hosts my.$APP_ENVIRONMENT.dp3.us,office.$APP_ENVIRONMENT.dp3.us,admin.$APP_ENVIRONMENT.dp3.us --tries 10 --backoff 3 --log-level info --timeout 5m + - echo "Running TLS Check" + - bin/tls-checker --schemes https --hosts my.$APP_ENVIRONMENT.dp3.us,office.$APP_ENVIRONMENT.dp3.us,admin.$APP_ENVIRONMENT.dp3.us --log-level info --timeout 15m + - echo "Checking deployed commits" + - ./scripts/check-deployed-commit "my.$APP_ENVIRONMENT.dp3.us,office.$APP_ENVIRONMENT.dp3.us,admin.$APP_ENVIRONMENT.dp3.us" "$CI_COMMIT_SHA" after_script: - *announce_failure rules: diff --git a/Dockerfile b/Dockerfile index d74fa75d42c..18f55ac4c63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ COPY build /build COPY public/static/react-file-viewer /public/static/react-file-viewer # Mount mutable tmp for app packages like pdfcpu +# hadolint ignore=DL3007 VOLUME ["/tmp"] ENTRYPOINT ["/bin/milmove"] diff --git a/Dockerfile.dp3 b/Dockerfile.dp3 index b9b420cdeb0..bd32b33446e 100644 --- a/Dockerfile.dp3 +++ b/Dockerfile.dp3 @@ -25,6 +25,7 @@ COPY build /build COPY public/static/react-file-viewer /public/static/react-file-viewer # Mount mutable tmp for app packages like pdfcpu +# hadolint ignore=DL3007 VOLUME ["/tmp"] ENTRYPOINT ["/bin/milmove"] diff --git a/config/tls/milmove-cert-bundle.p7b b/config/tls/milmove-cert-bundle.p7b index 85eb6a72d7f..755ddc7b08a 100644 Binary files a/config/tls/milmove-cert-bundle.p7b and b/config/tls/milmove-cert-bundle.p7b differ diff --git a/migrations/app/migrations_manifest.txt b/migrations/app/migrations_manifest.txt index 20fd4c31d51..564ae241e40 100644 --- a/migrations/app/migrations_manifest.txt +++ b/migrations/app/migrations_manifest.txt @@ -1059,6 +1059,7 @@ 20241217163231_update_duty_locations_bad_zips.up.sql 20241217180136_add_AK_zips_to_zip3_distances.up.sql 20241218201833_add_PPPO_BASE_ELIZABETH.up.sql +20241218204620_add_international_nts_service_items.up.sql 20241220171035_add_additional_AK_zips_to_zip3_distances.up.sql 20241220213134_add_destination_gbloc_db_function.up.sql 20241224172258_add_and_update_po_box_zip.up.sql @@ -1069,4 +1070,6 @@ 20241230190647_add_missing_AK_zips_to_zip3_distances.up.sql 20250103180420_update_pricing_proc_to_use_local_price_variable.up.sql 20250110001339_update_nts_release_enum_name.up.sql +20250110153428_add_shipment_address_updates_to_move_history.up.sql 20250110214012_homesafeconnect_cert.up.sql +20250116200912_disable_homesafe_stg_cert.up.sql diff --git a/migrations/app/schema/20241218204620_add_international_nts_service_items.up.sql b/migrations/app/schema/20241218204620_add_international_nts_service_items.up.sql new file mode 100644 index 00000000000..207c5379080 --- /dev/null +++ b/migrations/app/schema/20241218204620_add_international_nts_service_items.up.sql @@ -0,0 +1,36 @@ +-- +-- Add service items for international NTS shipments. +-- +INSERT INTO re_service_items +(id, service_id, shipment_type, market_code, is_auto_approved, created_at, updated_at, sort) +VALUES + --ISLH International Shipping & Linehaul + ('2a560507-db09-4be1-b809-49c0f515b31b', '9f3d551a-0725-430e-897e-80ee9add3ae9' ,'HHG_INTO_NTS', 'i', true, now(), now(), 1), + --PODFSC International POD Fuel Surcharge + ('e702818f-defd-452c-81a3-865b902e7dd0', '388115e8-abe9-441d-96cf-a39f24baa0a3' ,'HHG_INTO_NTS', 'i', true, now(), now(), 2), + --INPK International NTS packing + ('366ee5a4-eb61-4ded-a68c-ddc29fe1a886', '874cb86a-bc39-4f57-a614-53ee3fcacf14' ,'HHG_INTO_NTS', 'i', true, now(), now(), 3), + --ICRT International crating + ('aac4e95e-27ed-4f09-9b6b-384d8542f410', '86203d72-7f7c-49ff-82f0-5b95e4958f60' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL), + --IDASIT International destination add'l day SIT + ('010f2f91-7381-4149-8d74-8eb5f593a864', '806c6d59-57ff-4a3f-9518-ebf29ba9cb10' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL), + --IDDSIT International destination SIT delivery + ('a41966b7-b83a-4eaf-8e68-d5e884777102', '28389ee1-56cf-400c-aa52-1501ecdd7c69' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL), + --IDFSIT International destination 1st day SIT + ('14c77957-3c76-465a-bb07-c98d36ef1e54', 'bd6064ca-e780-4ab4-a37b-0ae98eebb244' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL), + --IDSHUT International destination shuttle service + ('d52d2d03-100a-4ed9-b2de-16eac63a375f', '22fc07ed-be15-4f50-b941-cbd38153b378' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL), + --IOASIT International origin add'l day SIT + ('7fd91408-7d69-4375-b7e6-5b2ff714206b', 'bd424e45-397b-4766-9712-de4ae3a2da36' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL), + --IOFSIT International origin 1st day SIT + ('b3dc509d-d652-4300-a702-a1ddce6255b6', 'b488bf85-ea5e-49c8-ba5c-e2fa278ac806' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL), + --IOPSIT International origin SIT pickup + ('001eadb6-3526-45b9-96e0-0648bb481e86', '6f4f6e31-0675-4051-b659-89832259f390' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL), + --IOSHUT International origin shuttle service + ('b991c5c9-af2c-4146-b999-1d0bdf91de3f', '624a97c5-dfbf-4da9-a6e9-526b4f95af8d' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL), + --IUCRT International uncrating + ('5a89315a-257b-4ef0-92cb-4c06aa6f1332', '4132416b-b1aa-42e7-98f2-0ac0a03e8a31' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL), + --IOFSC International Origin SIT Fuel Surcharge + ('d4a98dea-a5f7-4b92-b5de-e6350ab07824', '81e29d0c-02a6-4a7a-be02-554deb3ee49e' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL), + --IDSFSC International Destination SIT Fuel Surcharge + ('eaea90c2-93d3-4db9-89cd-23ac57ec9ce1', '690a5fc1-0ea5-4554-8294-a367b5daefa9' ,'HHG_INTO_NTS', 'i', false, now(), now(), NULL); diff --git a/migrations/app/schema/20250110153428_add_shipment_address_updates_to_move_history.up.sql b/migrations/app/schema/20250110153428_add_shipment_address_updates_to_move_history.up.sql new file mode 100644 index 00000000000..ec30212f12c --- /dev/null +++ b/migrations/app/schema/20250110153428_add_shipment_address_updates_to_move_history.up.sql @@ -0,0 +1,9 @@ +-- adding shipment_address_updates table to move history so we can track the activity +SELECT add_audit_history_table( + target_table := 'shipment_address_updates', + audit_rows := BOOLEAN 't', + audit_query_text := BOOLEAN 't', + ignored_cols := ARRAY[ + 'created_at' + ] +); \ No newline at end of file diff --git a/migrations/app/secure/20250116200912_disable_homesafe_stg_cert.up.sql b/migrations/app/secure/20250116200912_disable_homesafe_stg_cert.up.sql new file mode 100644 index 00000000000..f9862f58a7c --- /dev/null +++ b/migrations/app/secure/20250116200912_disable_homesafe_stg_cert.up.sql @@ -0,0 +1,4 @@ +-- Local test migration. +-- This will be run on development environments. +-- It should mirror what you intend to apply on prd/stg/exp/demo +-- DO NOT include any sensitive data. diff --git a/pkg/assets/sql_scripts/move_history_fetcher.sql b/pkg/assets/sql_scripts/move_history_fetcher.sql index dacacf55d78..28f88eab583 100644 --- a/pkg/assets/sql_scripts/move_history_fetcher.sql +++ b/pkg/assets/sql_scripts/move_history_fetcher.sql @@ -642,6 +642,25 @@ WITH move AS ( JOIN gsr_appeals ON gsr_appeals.id = audit_history.object_id WHERE audit_history.table_name = 'gsr_appeals' ), + shipment_address_updates AS ( + SELECT shipment_address_updates.*, + jsonb_agg(jsonb_build_object( + 'status', shipment_address_updates.status + ) + )::TEXT AS context + FROM shipment_address_updates + JOIN move_shipments ON shipment_address_updates.shipment_id = move_shipments.id + GROUP BY shipment_address_updates.id + ), + shipment_address_updates_logs as ( + SELECT audit_history.*, + shipment_address_updates.context AS context, + NULL AS context_id + FROM + audit_history + JOIN shipment_address_updates ON shipment_address_updates.id = audit_history.object_id + WHERE audit_history.table_name = 'shipment_address_updates' + ), combined_logs AS ( SELECT * @@ -732,6 +751,11 @@ WITH move AS ( * FROM gsr_appeals_logs + UNION + SELECT + * + FROM + shipment_address_updates_logs ) diff --git a/pkg/factory/mto_shipment_factory.go b/pkg/factory/mto_shipment_factory.go index 89efcc51e2f..0dc95a166e6 100644 --- a/pkg/factory/mto_shipment_factory.go +++ b/pkg/factory/mto_shipment_factory.go @@ -57,7 +57,7 @@ func buildMTOShipmentWithBuildType(db *pop.Connection, customs []Customization, defaultStatus = models.MTOShipmentStatusDraft buildStorageFacility = hasStorageFacilityCustom shipmentHasPickupDetails = true - shipmentHasDeliveryDetails = false + shipmentHasDeliveryDetails = true case mtoShipmentNTSR: defaultShipmentType = models.MTOShipmentTypeHHGOutOfNTS defaultStatus = models.MTOShipmentStatusDraft @@ -83,6 +83,10 @@ func buildMTOShipmentWithBuildType(db *pop.Connection, customs []Customization, MarketCode: defaultMarketCode, } + if newMTOShipment.ShipmentType == models.MTOShipmentTypeHHGIntoNTS && newMTOShipment.StorageFacility != nil { + newMTOShipment.DestinationAddress = &newMTOShipment.StorageFacility.Address + } + if cMtoShipment.Status == models.MTOShipmentStatusApproved { approvedDate := time.Date(GHCTestYear, time.March, 20, 0, 0, 0, 0, time.UTC) newMTOShipment.ApprovedDate = &approvedDate diff --git a/pkg/factory/mto_shipment_factory_test.go b/pkg/factory/mto_shipment_factory_test.go index 797164a616b..db0c04dd979 100644 --- a/pkg/factory/mto_shipment_factory_test.go +++ b/pkg/factory/mto_shipment_factory_test.go @@ -450,9 +450,9 @@ func (suite *FactorySuite) TestBuildMTOShipment() { suite.NotNil(ntsShipment.PrimeActualWeight) suite.Nil(ntsShipment.StorageFacility) suite.NotNil(ntsShipment.ScheduledPickupDate) - suite.Nil(ntsShipment.RequestedDeliveryDate) + suite.NotNil(ntsShipment.RequestedDeliveryDate) suite.Nil(ntsShipment.ActualDeliveryDate) - suite.Nil(ntsShipment.ScheduledDeliveryDate) + suite.NotNil(ntsShipment.ScheduledDeliveryDate) }) suite.Run("Successful creation of NTSShipment with storage facility", func() { diff --git a/pkg/gen/ghcapi/embedded_spec.go b/pkg/gen/ghcapi/embedded_spec.go index 807fcb73fa8..3db3ec66e71 100644 --- a/pkg/gen/ghcapi/embedded_spec.go +++ b/pkg/gen/ghcapi/embedded_spec.go @@ -5775,7 +5775,8 @@ func init() { "application/json" ], "tags": [ - "shipment" + "shipment", + "shipment_address_updates" ], "summary": "Allows TOO to review a shipment address update", "operationId": "reviewShipmentAddressUpdate", @@ -7081,6 +7082,11 @@ func init() { "grade": { "$ref": "#/definitions/Grade" }, + "hasDependents": { + "type": "boolean", + "title": "Are dependents included in your orders?", + "x-nullable": true + }, "issueDate": { "description": "The date and time that these orders were cut.", "type": "string", @@ -22663,7 +22669,8 @@ func init() { "application/json" ], "tags": [ - "shipment" + "shipment", + "shipment_address_updates" ], "summary": "Allows TOO to review a shipment address update", "operationId": "reviewShipmentAddressUpdate", @@ -24121,6 +24128,11 @@ func init() { "grade": { "$ref": "#/definitions/Grade" }, + "hasDependents": { + "type": "boolean", + "title": "Are dependents included in your orders?", + "x-nullable": true + }, "issueDate": { "description": "The date and time that these orders were cut.", "type": "string", diff --git a/pkg/gen/ghcapi/ghcoperations/shipment/review_shipment_address_update.go b/pkg/gen/ghcapi/ghcoperations/shipment/review_shipment_address_update.go index d4532a282ce..61dafe8bc53 100644 --- a/pkg/gen/ghcapi/ghcoperations/shipment/review_shipment_address_update.go +++ b/pkg/gen/ghcapi/ghcoperations/shipment/review_shipment_address_update.go @@ -36,7 +36,7 @@ func NewReviewShipmentAddressUpdate(ctx *middleware.Context, handler ReviewShipm } /* - ReviewShipmentAddressUpdate swagger:route PATCH /shipments/{shipmentID}/review-shipment-address-update shipment reviewShipmentAddressUpdate + ReviewShipmentAddressUpdate swagger:route PATCH /shipments/{shipmentID}/review-shipment-address-update shipment shipment_address_updates reviewShipmentAddressUpdate # Allows TOO to review a shipment address update diff --git a/pkg/gen/ghcmessages/counseling_update_order_payload.go b/pkg/gen/ghcmessages/counseling_update_order_payload.go index a03a99a22de..281972b5196 100644 --- a/pkg/gen/ghcmessages/counseling_update_order_payload.go +++ b/pkg/gen/ghcmessages/counseling_update_order_payload.go @@ -26,6 +26,9 @@ type CounselingUpdateOrderPayload struct { // grade Grade *Grade `json:"grade,omitempty"` + // Are dependents included in your orders? + HasDependents *bool `json:"hasDependents,omitempty"` + // Orders date // // The date and time that these orders were cut. diff --git a/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go b/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go index 2a713d706b8..46c58c10ea1 100644 --- a/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go +++ b/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go @@ -682,6 +682,7 @@ func Order(order *models.Order) *ghcmessages.Order { MoveCode: moveCode, MoveTaskOrderID: moveTaskOrderID, OriginDutyLocationGBLOC: ghcmessages.GBLOC(swag.StringValue(order.OriginDutyLocationGBLOC)), + HasDependents: order.HasDependents, } return &payload diff --git a/pkg/models/re_contract.go b/pkg/models/re_contract.go index 2c4b4a28e35..c0576ce403b 100644 --- a/pkg/models/re_contract.go +++ b/pkg/models/re_contract.go @@ -1,12 +1,17 @@ package models import ( + "database/sql" + "fmt" "time" "github.com/gobuffalo/pop/v6" "github.com/gobuffalo/validate/v3" "github.com/gobuffalo/validate/v3/validators" "github.com/gofrs/uuid" + + "github.com/transcom/mymove/pkg/appcontext" + "github.com/transcom/mymove/pkg/apperror" ) // ReContract represents a contract with pricing information @@ -32,3 +37,30 @@ func (r *ReContract) Validate(_ *pop.Connection) (*validate.Errors, error) { &validators.StringIsPresent{Field: r.Name, Name: "Name"}, ), nil } + +func FetchContractForMove(appCtx appcontext.AppContext, moveID uuid.UUID) (ReContract, error) { + var move Move + err := appCtx.DB().Find(&move, moveID) + if err != nil { + if err == sql.ErrNoRows { + return ReContract{}, apperror.NewNotFoundError(moveID, "looking for Move") + } + return ReContract{}, err + } + + if move.AvailableToPrimeAt == nil { + return ReContract{}, apperror.NewConflictError(moveID, "unable to pick contract because move is not available to prime") + } + + var contractYear ReContractYear + err = appCtx.DB().EagerPreload("Contract").Where("? between start_date and end_date", move.AvailableToPrimeAt). + First(&contractYear) + if err != nil { + if err == sql.ErrNoRows { + return ReContract{}, apperror.NewNotFoundError(uuid.Nil, fmt.Sprintf("no contract year found for %s", move.AvailableToPrimeAt.String())) + } + return ReContract{}, err + } + + return contractYear.Contract, nil +} diff --git a/pkg/models/re_contract_test.go b/pkg/models/re_contract_test.go index c2148951ede..9fca3401f22 100644 --- a/pkg/models/re_contract_test.go +++ b/pkg/models/re_contract_test.go @@ -1,7 +1,11 @@ package models_test import ( + "time" + + "github.com/transcom/mymove/pkg/factory" "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/testdatagen" ) func (suite *ModelSuite) TestReContractValidations() { @@ -23,3 +27,30 @@ func (suite *ModelSuite) TestReContractValidations() { suite.verifyValidationErrors(&emptyReContract, expErrors) }) } + +func (suite *ModelSuite) TestFetchContractForMove() { + suite.Run("finds valid contract", func() { + reContract := testdatagen.FetchOrMakeReContract(suite.DB(), testdatagen.Assertions{}) + testdatagen.FetchOrMakeReContractYear(suite.DB(), testdatagen.Assertions{ + ReContractYear: models.ReContractYear{ + Contract: reContract, + ContractID: reContract.ID, + StartDate: time.Now(), + EndDate: time.Now().Add(time.Hour * 12), + Escalation: 1.0, + EscalationCompounded: 1.0, + }, + }) + move := factory.BuildAvailableToPrimeMove(suite.DB(), nil, nil) + contract, err := models.FetchContractForMove(suite.AppContextForTest(), move.ID) + suite.NoError(err) + suite.Equal(contract.ID, reContract.ID) + }) + + suite.Run("returns error if no contract found", func() { + move := factory.BuildAvailableToPrimeMove(suite.DB(), nil, nil) + contract, err := models.FetchContractForMove(suite.AppContextForTest(), move.ID) + suite.Error(err) + suite.Equal(contract, models.ReContract{}) + }) +} diff --git a/pkg/models/re_oconus_rate_areas.go b/pkg/models/re_oconus_rate_areas.go index 72b85773159..84def705f95 100644 --- a/pkg/models/re_oconus_rate_areas.go +++ b/pkg/models/re_oconus_rate_areas.go @@ -3,6 +3,7 @@ package models import ( "time" + "github.com/gobuffalo/pop/v6" "github.com/gofrs/uuid" ) @@ -19,3 +20,16 @@ type OconusRateArea struct { func (o OconusRateArea) TableName() string { return "re_oconus_rate_areas" } + +func FetchOconusRateArea(db *pop.Connection, zip string) (*OconusRateArea, error) { + var reOconusRateArea OconusRateArea + err := db.Q(). + InnerJoin("re_rate_areas ra", "re_oconus_rate_areas.rate_area_id = ra.id"). + InnerJoin("us_post_region_cities upc", "upc.id = re_oconus_rate_areas.us_post_region_cities_id"). + Where("upc.uspr_zip_id = ?", zip). + First(&reOconusRateArea) + if err != nil { + return nil, err + } + return &reOconusRateArea, nil +} diff --git a/pkg/models/re_rate_area.go b/pkg/models/re_rate_area.go index 7b613b42a28..8eb7c56328f 100644 --- a/pkg/models/re_rate_area.go +++ b/pkg/models/re_rate_area.go @@ -56,8 +56,8 @@ func FetchReRateAreaItem(tx *pop.Connection, contractID uuid.UUID, code string) } // a db stored proc that takes in an address id & a service code to get the rate area id for an address -func FetchRateAreaID(db *pop.Connection, addressID uuid.UUID, serviceID uuid.UUID, contractID uuid.UUID) (uuid.UUID, error) { - if addressID != uuid.Nil && serviceID != uuid.Nil && contractID != uuid.Nil { +func FetchRateAreaID(db *pop.Connection, addressID uuid.UUID, serviceID *uuid.UUID, contractID uuid.UUID) (uuid.UUID, error) { + if addressID != uuid.Nil && contractID != uuid.Nil { var rateAreaID uuid.UUID err := db.RawQuery("SELECT get_rate_area_id($1, $2, $3)", addressID, serviceID, contractID).First(&rateAreaID) if err != nil { @@ -67,3 +67,17 @@ func FetchRateAreaID(db *pop.Connection, addressID uuid.UUID, serviceID uuid.UUI } return uuid.Nil, fmt.Errorf("error fetching rate area ID - required parameters not provided") } + +func FetchConusRateAreaByPostalCode(db *pop.Connection, zip string, contractID uuid.UUID) (*ReRateArea, error) { + var reRateArea ReRateArea + postalCode := zip[0:3] + err := db.Q(). + InnerJoin("re_zip3s rz", "rz.rate_area_id = re_rate_areas.id"). + Where("zip3 = ?", postalCode). + Where("re_rate_areas.contract_id = ?", contractID). + First(&reRateArea) + if err != nil { + return nil, err + } + return &reRateArea, nil +} diff --git a/pkg/models/re_rate_area_test.go b/pkg/models/re_rate_area_test.go index 87f310c2088..ab279418976 100644 --- a/pkg/models/re_rate_area_test.go +++ b/pkg/models/re_rate_area_test.go @@ -36,16 +36,14 @@ func (suite *ModelSuite) TestFetchRateAreaID() { service := factory.FetchReServiceByCode(suite.DB(), models.ReServiceCodeIHPK) contract := testdatagen.FetchOrMakeReContract(suite.DB(), testdatagen.Assertions{}) address := factory.BuildAddress(suite.DB(), nil, nil) - rateAreaId, err := models.FetchRateAreaID(suite.DB(), address.ID, service.ID, contract.ID) + rateAreaId, err := models.FetchRateAreaID(suite.DB(), address.ID, &service.ID, contract.ID) suite.NotNil(rateAreaId) suite.NoError(err) }) suite.Run("fail - receive error when not all values are provided", func() { - var nilUuid uuid.UUID - contract := testdatagen.FetchOrMakeReContract(suite.DB(), testdatagen.Assertions{}) address := factory.BuildAddress(suite.DB(), nil, nil) - rateAreaId, err := models.FetchRateAreaID(suite.DB(), address.ID, nilUuid, contract.ID) + rateAreaId, err := models.FetchRateAreaID(suite.DB(), address.ID, nil, uuid.Nil) suite.Equal(uuid.Nil, rateAreaId) suite.Error(err) }) diff --git a/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup.go b/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup.go index b339fbf43dd..68d59f13d27 100644 --- a/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup.go +++ b/pkg/payment_request/service_param_value_lookups/per_unit_cents_lookup.go @@ -24,7 +24,7 @@ func (p PerUnitCentsLookup) lookup(appCtx appcontext.AppContext, s *ServiceItemP switch p.ServiceItem.ReService.Code { case models.ReServiceCodeIHPK: // IHPK: Need rate area ID for the pickup address - rateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.PickupAddressID, serviceID, contractID) + rateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.PickupAddressID, &serviceID, contractID) if err != nil { return "", fmt.Errorf("error fetching rate area id for shipment ID: %s and service ID %s: %s", p.MTOShipment.ID, serviceID, err) } @@ -43,7 +43,7 @@ func (p PerUnitCentsLookup) lookup(appCtx appcontext.AppContext, s *ServiceItemP case models.ReServiceCodeIHUPK: // IHUPK: Need rate area ID for the destination address - rateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.PickupAddressID, serviceID, contractID) + rateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.PickupAddressID, &serviceID, contractID) if err != nil { return "", fmt.Errorf("error fetching rate area id for shipment ID: %s and service ID %s: %s", p.MTOShipment.ID, serviceID, err) } @@ -62,11 +62,11 @@ func (p PerUnitCentsLookup) lookup(appCtx appcontext.AppContext, s *ServiceItemP case models.ReServiceCodeISLH: // ISLH: Need rate area IDs for origin and destination - originRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.PickupAddressID, serviceID, contractID) + originRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.PickupAddressID, &serviceID, contractID) if err != nil { return "", fmt.Errorf("error fetching rate area id for origin address for shipment ID: %s and service ID %s: %s", p.MTOShipment.ID, serviceID, err) } - destRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.DestinationAddressID, serviceID, contractID) + destRateAreaID, err := models.FetchRateAreaID(appCtx.DB(), *p.MTOShipment.DestinationAddressID, &serviceID, contractID) if err != nil { return "", fmt.Errorf("error fetching rate area id for destination address for shipment ID: %s and service ID %s: %s", p.MTOShipment.ID, serviceID, err) } diff --git a/pkg/services/move_history/move_history_fetcher_test.go b/pkg/services/move_history/move_history_fetcher_test.go index 9da8d13f3bb..161dde49e93 100644 --- a/pkg/services/move_history/move_history_fetcher_test.go +++ b/pkg/services/move_history/move_history_fetcher_test.go @@ -252,8 +252,10 @@ func (suite *MoveHistoryServiceSuite) TestMoveHistoryFetcherFunctionality() { auditHistoryContains := func(auditHistories models.AuditHistories, keyword string) func() (success bool) { return func() (success bool) { for _, record := range auditHistories { - if strings.Contains(*record.ChangedData, keyword) { - return true + if record.ChangedData != nil { + if strings.Contains(*record.ChangedData, keyword) { + return true + } } } return false diff --git a/pkg/services/mto_shipment/mto_shipment_updater.go b/pkg/services/mto_shipment/mto_shipment_updater.go index 280f223c551..e83e6a6e223 100644 --- a/pkg/services/mto_shipment/mto_shipment_updater.go +++ b/pkg/services/mto_shipment/mto_shipment_updater.go @@ -855,8 +855,8 @@ func (f *mtoShipmentUpdater) updateShipmentRecord(appCtx appcontext.AppContext, // we will compare data here to see if we even need to update the pricing if newShipment.MarketCode == models.MarketCodeInternational && (newShipment.PrimeEstimatedWeight != nil || - newShipment.PickupAddress != nil && newShipment.PickupAddress.PostalCode != dbShipment.PickupAddress.PostalCode || - newShipment.DestinationAddress != nil && newShipment.DestinationAddress.PostalCode != dbShipment.DestinationAddress.PostalCode || + newShipment.PickupAddress != nil && dbShipment.PickupAddress != nil && newShipment.PickupAddress.PostalCode != dbShipment.PickupAddress.PostalCode || + newShipment.DestinationAddress != nil && dbShipment.DestinationAddress != nil && newShipment.DestinationAddress.PostalCode != dbShipment.DestinationAddress.PostalCode || newShipment.RequestedPickupDate != nil && newShipment.RequestedPickupDate.Format("2006-01-02") != dbShipment.RequestedPickupDate.Format("2006-01-02")) { portZip, portType, err := models.GetPortLocationInfoForShipment(appCtx.DB(), newShipment.ID) @@ -1110,9 +1110,9 @@ func reServiceCodesForShipment(shipment models.MTOShipment) []models.ReServiceCo // More info in MB-1140: https://dp3.atlassian.net/browse/MB-1140 // international shipment service items are created in the shipment_approver - switch shipment.ShipmentType { - case models.MTOShipmentTypeHHG: - if shipment.MarketCode != models.MarketCodeInternational { + if shipment.MarketCode != models.MarketCodeInternational { + switch shipment.ShipmentType { + case models.MTOShipmentTypeHHG: originZIP3 := shipment.PickupAddress.PostalCode[0:3] destinationZIP3 := shipment.DestinationAddress.PostalCode[0:3] @@ -1136,51 +1136,51 @@ func reServiceCodesForShipment(shipment models.MTOShipment) []models.ReServiceCo models.ReServiceCodeDPK, models.ReServiceCodeDUPK, } - } - case models.MTOShipmentTypeHHGIntoNTS: - // Need to create: Dom Linehaul, Fuel Surcharge, Dom Origin Price, Dom Destination Price, Dom NTS Packing - return []models.ReServiceCode{ - models.ReServiceCodeDLH, - models.ReServiceCodeFSC, - models.ReServiceCodeDOP, - models.ReServiceCodeDDP, - models.ReServiceCodeDNPK, - } - case models.MTOShipmentTypeHHGOutOfNTS: - // Need to create: Dom Linehaul, Fuel Surcharge, Dom Origin Price, Dom Destination Price, Dom Unpacking - return []models.ReServiceCode{ - models.ReServiceCodeDLH, - models.ReServiceCodeFSC, - models.ReServiceCodeDOP, - models.ReServiceCodeDDP, - models.ReServiceCodeDUPK, - } - case models.MTOShipmentTypeMobileHome: - // Need to create: Dom Linehaul, Fuel Surcharge, Dom Origin Price, Dom Destination Price, Dom Mobile Home Factor - return []models.ReServiceCode{ - models.ReServiceCodeDLH, - models.ReServiceCodeFSC, - models.ReServiceCodeDOP, - models.ReServiceCodeDDP, - models.ReServiceCodeDMHF, - } - case models.MTOShipmentTypeBoatHaulAway: - // Need to create: Dom Linehaul, Fuel Surcharge, Dom Origin Price, Dom Destination Price, Dom Haul Away Boat Factor - return []models.ReServiceCode{ - models.ReServiceCodeDLH, - models.ReServiceCodeFSC, - models.ReServiceCodeDOP, - models.ReServiceCodeDDP, - models.ReServiceCodeDBHF, - } - case models.MTOShipmentTypeBoatTowAway: - // Need to create: Dom Linehaul, Fuel Surcharge, Dom Origin Price, Dom Destination Price, Dom Tow Away Boat Factor - return []models.ReServiceCode{ - models.ReServiceCodeDLH, - models.ReServiceCodeFSC, - models.ReServiceCodeDOP, - models.ReServiceCodeDDP, - models.ReServiceCodeDBTF, + case models.MTOShipmentTypeHHGIntoNTS: + // Need to create: Dom Linehaul, Fuel Surcharge, Dom Origin Price, Dom Destination Price, Dom NTS Packing + return []models.ReServiceCode{ + models.ReServiceCodeDLH, + models.ReServiceCodeFSC, + models.ReServiceCodeDOP, + models.ReServiceCodeDDP, + models.ReServiceCodeDNPK, + } + case models.MTOShipmentTypeHHGOutOfNTS: + // Need to create: Dom Linehaul, Fuel Surcharge, Dom Origin Price, Dom Destination Price, Dom Unpacking + return []models.ReServiceCode{ + models.ReServiceCodeDLH, + models.ReServiceCodeFSC, + models.ReServiceCodeDOP, + models.ReServiceCodeDDP, + models.ReServiceCodeDUPK, + } + case models.MTOShipmentTypeMobileHome: + // Need to create: Dom Linehaul, Fuel Surcharge, Dom Origin Price, Dom Destination Price, Dom Mobile Home Factor + return []models.ReServiceCode{ + models.ReServiceCodeDLH, + models.ReServiceCodeFSC, + models.ReServiceCodeDOP, + models.ReServiceCodeDDP, + models.ReServiceCodeDMHF, + } + case models.MTOShipmentTypeBoatHaulAway: + // Need to create: Dom Linehaul, Fuel Surcharge, Dom Origin Price, Dom Destination Price, Dom Haul Away Boat Factor + return []models.ReServiceCode{ + models.ReServiceCodeDLH, + models.ReServiceCodeFSC, + models.ReServiceCodeDOP, + models.ReServiceCodeDDP, + models.ReServiceCodeDBHF, + } + case models.MTOShipmentTypeBoatTowAway: + // Need to create: Dom Linehaul, Fuel Surcharge, Dom Origin Price, Dom Destination Price, Dom Tow Away Boat Factor + return []models.ReServiceCode{ + models.ReServiceCodeDLH, + models.ReServiceCodeFSC, + models.ReServiceCodeDOP, + models.ReServiceCodeDDP, + models.ReServiceCodeDBTF, + } } } diff --git a/pkg/services/mto_shipment/mto_shipment_updater_test.go b/pkg/services/mto_shipment/mto_shipment_updater_test.go index 673166ea591..63d4af96dc8 100644 --- a/pkg/services/mto_shipment/mto_shipment_updater_test.go +++ b/pkg/services/mto_shipment/mto_shipment_updater_test.go @@ -3445,3 +3445,104 @@ func (suite *MTOShipmentServiceSuite) TestUpdateStatusServiceItems() { suite.Equal(models.ReServiceCodeDSH, serviceItems[0].ReService.Code) }) } + +func (suite *MTOShipmentServiceSuite) TestUpdateDomesticServiceItems() { + + expectedReServiceCodes := []models.ReServiceCode{ + models.ReServiceCodeDLH, + models.ReServiceCodeFSC, + models.ReServiceCodeDOP, + models.ReServiceCodeDDP, + models.ReServiceCodeDNPK, + } + + var pickupAddress models.Address + var storageFacility models.StorageFacility + var mto models.Move + + setupTestData := func() { + pickupAddress = factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "Test Street 1", + City: "Des moines", + State: "IA", + PostalCode: "50309", + IsOconus: models.BoolPointer(false), + }, + }, + }, nil) + + storageFacility = factory.BuildStorageFacility(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "Test Street Adress 2", + City: "Des moines", + State: "IA", + PostalCode: "50314", + IsOconus: models.BoolPointer(false), + }, + }, + }, nil) + + mto = factory.BuildMove(suite.DB(), []factory.Customization{ + { + Model: models.Move{ + Status: models.MoveStatusAPPROVED, + }, + }, + }, nil) + } + + builder := query.NewQueryBuilder() + moveRouter := moveservices.NewMoveRouter() + planner := &mocks.Planner{} + planner.On("ZipTransitDistance", + mock.AnythingOfType("*appcontext.appContext"), + mock.Anything, + mock.Anything, + ).Return(400, nil) + siCreator := mtoserviceitem.NewMTOServiceItemCreator(planner, builder, moveRouter, ghcrateengine.NewDomesticUnpackPricer(), ghcrateengine.NewDomesticPackPricer(), ghcrateengine.NewDomesticLinehaulPricer(), ghcrateengine.NewDomesticShorthaulPricer(), ghcrateengine.NewDomesticOriginPricer(), ghcrateengine.NewDomesticDestinationPricer(), ghcrateengine.NewFuelSurchargePricer()) + updater := NewMTOShipmentStatusUpdater(builder, siCreator, planner) + + suite.Run("Preapproved service items successfully added to domestic nts shipments", func() { + setupTestData() + + shipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ + { + Model: mto, + LinkOnly: true, + }, + { + Model: pickupAddress, + Type: &factory.Addresses.PickupAddress, + LinkOnly: true, + }, + { + Model: storageFacility, + Type: &factory.StorageFacility, + LinkOnly: true, + }, + { + Model: models.MTOShipment{ + ShipmentType: models.MTOShipmentTypeHHGIntoNTS, + Status: models.MTOShipmentStatusSubmitted, + }, + }, + }, nil) + + appCtx := suite.AppContextForTest() + eTag := etag.GenerateEtag(shipment.UpdatedAt) + + updatedShipment, err := updater.UpdateMTOShipmentStatus(appCtx, shipment.ID, models.MTOShipmentStatusApproved, nil, nil, eTag) + suite.NoError(err) + + serviceItems := models.MTOServiceItems{} + err = appCtx.DB().EagerPreload("ReService").Where("mto_shipment_id = ?", updatedShipment.ID).All(&serviceItems) + suite.NoError(err) + + for i := 0; i < len(expectedReServiceCodes); i++ { + suite.Equal(expectedReServiceCodes[i], serviceItems[i].ReService.Code) + } + }) +} diff --git a/pkg/services/mto_shipment/shipment_approver.go b/pkg/services/mto_shipment/shipment_approver.go index 2a4d5d97fb9..b285684a62b 100644 --- a/pkg/services/mto_shipment/shipment_approver.go +++ b/pkg/services/mto_shipment/shipment_approver.go @@ -2,6 +2,7 @@ package mtoshipment import ( "math" + "slices" "github.com/gofrs/uuid" "github.com/pkg/errors" @@ -80,7 +81,8 @@ func (f *shipmentApprover) ApproveShipment(appCtx appcontext.AppContext, shipmen transactionError := appCtx.NewTransaction(func(txnAppCtx appcontext.AppContext) error { // create international shipment service items before approving // we use a database proc to create the basic auto-approved service items - if shipment.ShipmentType == models.MTOShipmentTypeHHG && shipment.MarketCode == models.MarketCodeInternational { + internationalShipmentTypes := []models.MTOShipmentType{models.MTOShipmentTypeHHG, models.MTOShipmentTypeHHGIntoNTS, models.MTOShipmentTypeUnaccompaniedBaggage} + if slices.Contains(internationalShipmentTypes, shipment.ShipmentType) && shipment.MarketCode == models.MarketCodeInternational { err := models.CreateApprovedServiceItemsForShipment(appCtx.DB(), shipment) if err != nil { return err diff --git a/pkg/services/mto_shipment/shipment_approver_test.go b/pkg/services/mto_shipment/shipment_approver_test.go index 309a87c0015..167cffca439 100644 --- a/pkg/services/mto_shipment/shipment_approver_test.go +++ b/pkg/services/mto_shipment/shipment_approver_test.go @@ -14,6 +14,7 @@ import ( "github.com/transcom/mymove/pkg/etag" "github.com/transcom/mymove/pkg/factory" "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/route" "github.com/transcom/mymove/pkg/route/mocks" "github.com/transcom/mymove/pkg/services" "github.com/transcom/mymove/pkg/services/ghcrateengine" @@ -299,7 +300,7 @@ func (suite *MTOShipmentServiceSuite) TestApproveShipment() { models.ReServiceCodeIHUPK, } - suite.Equal(4, len(serviceItems)) + suite.Equal(len(expectedReserviceCodes), len(serviceItems)) for i := 0; i < len(serviceItems); i++ { actualReServiceCode := serviceItems[i].ReService.Code suite.True(slices.Contains(expectedReserviceCodes, actualReServiceCode)) @@ -312,6 +313,163 @@ func (suite *MTOShipmentServiceSuite) TestApproveShipment() { } }) + suite.Run("Given international mtoShipment is approved successfully pre-approved mtoServiceItems are created NTS CONUS to OCONUS", func() { + storageFacility := factory.BuildStorageFacility(suite.DB(), []factory.Customization{ + { + Model: models.StorageFacility{ + FacilityName: *models.StringPointer("Test Storage Name"), + Email: models.StringPointer("old@email.com"), + LotNumber: models.StringPointer("Test lot number"), + Phone: models.StringPointer("555-555-5555"), + }, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "Anchorage", + State: "AK", + PostalCode: "99507", + IsOconus: models.BoolPointer(true), + }, + }, + }, nil) + + internationalShipment := factory.BuildMTOShipment(suite.DB(), []factory.Customization{ + { + Model: models.Move{ + Status: models.MoveStatusAPPROVED, + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Des Moines", + State: "IA", + PostalCode: "50314", + IsOconus: models.BoolPointer(false), + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + Status: models.MTOShipmentStatusSubmitted, + ShipmentType: models.MTOShipmentTypeHHGIntoNTS, + }, + }, + { + Model: storageFacility, + LinkOnly: true, + }, + }, nil) + internationalShipmentEtag := etag.GenerateEtag(internationalShipment.UpdatedAt) + + shipmentRouter := NewShipmentRouter() + var serviceItemCreator services.MTOServiceItemCreator + var planner route.Planner + var moveWeights services.MoveWeights + + // Approve international shipment + shipmentApprover := NewShipmentApprover(shipmentRouter, serviceItemCreator, planner, moveWeights) + _, err := shipmentApprover.ApproveShipment(suite.AppContextForTest(), internationalShipment.ID, internationalShipmentEtag) + suite.NoError(err) + + // Get created pre approved service items + var serviceItems []models.MTOServiceItem + err2 := suite.AppContextForTest().DB().EagerPreload("ReService").Where("mto_shipment_id = ?", internationalShipment.ID).Order("created_at asc").All(&serviceItems) + suite.NoError(err2) + + expectedReserviceCodes := []models.ReServiceCode{ + models.ReServiceCodeISLH, + models.ReServiceCodeINPK, + } + + suite.Equal(len(expectedReserviceCodes), len(serviceItems)) + for i := 0; i < len(serviceItems); i++ { + actualReServiceCode := serviceItems[i].ReService.Code + suite.True(slices.Contains(expectedReserviceCodes, actualReServiceCode)) + } + }) + + suite.Run("Given international mtoShipment is approved successfully pre-approved mtoServiceItems are created NTS OCONUS to CONUS", func() { + storageFacility := factory.BuildStorageFacility(suite.DB(), []factory.Customization{ + { + Model: models.StorageFacility{ + FacilityName: *models.StringPointer("Test Storage Name"), + Email: models.StringPointer("old@email.com"), + LotNumber: models.StringPointer("Test lot number"), + Phone: models.StringPointer("555-555-5555"), + }, + }, + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Des Moines", + State: "IA", + PostalCode: "50314", + IsOconus: models.BoolPointer(false), + }, + }, + }, nil) + + internationalShipment := factory.BuildNTSShipment(suite.DB(), []factory.Customization{ + { + Model: models.Move{ + Status: models.MoveStatusAPPROVED, + }, + }, + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "Anchorage", + State: "AK", + PostalCode: "99507", + IsOconus: models.BoolPointer(true), + }, + Type: &factory.Addresses.PickupAddress, + }, + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + Status: models.MTOShipmentStatusSubmitted, + ShipmentType: models.MTOShipmentTypeHHGIntoNTS, + }, + }, + { + Model: storageFacility, + LinkOnly: true, + }, + }, nil) + internationalShipmentEtag := etag.GenerateEtag(internationalShipment.UpdatedAt) + + shipmentRouter := NewShipmentRouter() + var serviceItemCreator services.MTOServiceItemCreator + var planner route.Planner + var moveWeights services.MoveWeights + + // Approve international shipment + shipmentApprover := NewShipmentApprover(shipmentRouter, serviceItemCreator, planner, moveWeights) + _, err := shipmentApprover.ApproveShipment(suite.AppContextForTest(), internationalShipment.ID, internationalShipmentEtag) + suite.NoError(err) + + // Get created pre approved service items + var serviceItems []models.MTOServiceItem + err2 := suite.AppContextForTest().DB().EagerPreload("ReService").Where("mto_shipment_id = ?", internationalShipment.ID).Order("created_at asc").All(&serviceItems) + suite.NoError(err2) + + expectedReserviceCodes := []models.ReServiceCode{ + models.ReServiceCodeISLH, + models.ReServiceCodePODFSC, + models.ReServiceCodeINPK, + } + + suite.Equal(len(expectedReserviceCodes), len(serviceItems)) + for i := 0; i < len(serviceItems); i++ { + actualReServiceCode := serviceItems[i].ReService.Code + suite.True(slices.Contains(expectedReserviceCodes, actualReServiceCode)) + } + }) + suite.Run("If the mtoShipment is approved successfully it should create approved mtoServiceItems", func() { subtestData := suite.createApproveShipmentSubtestData() appCtx := subtestData.appCtx @@ -854,4 +1012,25 @@ func (suite *MTOShipmentServiceSuite) TestApproveShipment() { suite.NotNil(shipment.MoveTaskOrder.ExcessWeightQualifiedAt) }) + + suite.Run("Given invalid shipment error returned", func() { + invalidShipment := factory.BuildMTOShipment(suite.AppContextForTest().DB(), []factory.Customization{ + { + Model: models.MTOShipment{ + ShipmentType: models.MTOShipmentTypePPM, + }, + }, + }, nil) + invalidShipmentEtag := etag.GenerateEtag(invalidShipment.UpdatedAt) + + shipmentRouter := NewShipmentRouter() + var serviceItemCreator services.MTOServiceItemCreator + var planner route.Planner + var moveWeights services.MoveWeights + + // Approve international shipment + shipmentApprover := NewShipmentApprover(shipmentRouter, serviceItemCreator, planner, moveWeights) + _, err := shipmentApprover.ApproveShipment(suite.AppContextForTest(), invalidShipment.ID, invalidShipmentEtag) + suite.Error(err) + }) } diff --git a/pkg/services/order/order_updater.go b/pkg/services/order/order_updater.go index 38c991a112a..02b8c848ca5 100644 --- a/pkg/services/order/order_updater.go +++ b/pkg/services/order/order_updater.go @@ -400,6 +400,10 @@ func orderFromCounselingPayload(existingOrder models.Order, payload ghcmessages. order.Entitlement.DBAuthorizedWeight = &weight } + if payload.HasDependents != nil { + order.HasDependents = *payload.HasDependents + } + return order } diff --git a/pkg/services/shipment_address_update/shipment_address_update_requester.go b/pkg/services/shipment_address_update/shipment_address_update_requester.go index 7ae14dbbeda..1a80f27538c 100644 --- a/pkg/services/shipment_address_update/shipment_address_update_requester.go +++ b/pkg/services/shipment_address_update/shipment_address_update_requester.go @@ -38,10 +38,10 @@ func NewShipmentAddressUpdateRequester(planner route.Planner, addressCreator ser } } -func (f *shipmentAddressUpdateRequester) isAddressChangeDistanceOver50(appCtx appcontext.AppContext, addressUpdate models.ShipmentAddressUpdate) (bool, error) { +func (f *shipmentAddressUpdateRequester) isAddressChangeDistanceOver50(appCtx appcontext.AppContext, addressUpdate models.ShipmentAddressUpdate, isInternationalShipment bool) (bool, error) { - //We calculate and set the distance between the old and new address - distance, err := f.planner.ZipTransitDistance(appCtx, addressUpdate.OriginalAddress.PostalCode, addressUpdate.NewAddress.PostalCode, false, false) + // We calculate and set the distance between the old and new address + distance, err := f.planner.ZipTransitDistance(appCtx, addressUpdate.OriginalAddress.PostalCode, addressUpdate.NewAddress.PostalCode, false, isInternationalShipment) if err != nil { return false, err } @@ -52,32 +52,62 @@ func (f *shipmentAddressUpdateRequester) isAddressChangeDistanceOver50(appCtx ap return true, nil } -func (f *shipmentAddressUpdateRequester) doesDeliveryAddressUpdateChangeServiceArea(appCtx appcontext.AppContext, contractID uuid.UUID, originalDeliveryAddress models.Address, newDeliveryAddress models.Address) (bool, error) { - var existingServiceArea models.ReZip3 - var actualServiceArea models.ReZip3 +func (f *shipmentAddressUpdateRequester) doesDeliveryAddressUpdateChangeServiceOrRateArea(appCtx appcontext.AppContext, contractID uuid.UUID, originalDeliveryAddress models.Address, newDeliveryAddress models.Address, shipment models.MTOShipment) (bool, error) { + // international shipments find their rate areas differently than domestic + if shipment.MarketCode == models.MarketCodeInternational { + // we already have the origin address in the db so we can check the rate area using the db func + originalRateArea, err := models.FetchRateAreaID(appCtx.DB(), originalDeliveryAddress.ID, nil, contractID) + if err != nil || originalRateArea == uuid.Nil { + return false, err + } + // since the new address isn't created yet we can't use the db func since it doesn't have an id, + // we need to manually find the rate area using the postal code + var updateRateArea uuid.UUID + newRateArea, err := models.FetchOconusRateArea(appCtx.DB(), newDeliveryAddress.PostalCode) + if err != nil && err != sql.ErrNoRows { + return false, err + } else if err == sql.ErrNoRows { // if we got no rows then the new address is likely CONUS + newRateArea, err := models.FetchConusRateAreaByPostalCode(appCtx.DB(), newDeliveryAddress.PostalCode, contractID) + if err != nil && err != sql.ErrNoRows { + return false, err + } + updateRateArea = newRateArea.ID + } else { + updateRateArea = newRateArea.RateAreaId + } + // if these are different, we need the TOO to approve this request since it will change ISLH pricing + if originalRateArea != updateRateArea { + return true, nil + } else { + return false, nil + } + } else { + var existingServiceArea models.ReZip3 + var actualServiceArea models.ReZip3 - originalZip := originalDeliveryAddress.PostalCode[0:3] - destinationZip := newDeliveryAddress.PostalCode[0:3] + originalZip := originalDeliveryAddress.PostalCode[0:3] + destinationZip := newDeliveryAddress.PostalCode[0:3] - if originalZip == destinationZip { - // If the ZIP hasn't changed, we must be in the same service area - return false, nil - } + if originalZip == destinationZip { + // If the ZIP hasn't changed, we must be in the same service area + return false, nil + } - err := appCtx.DB().Where("zip3 = ?", originalZip).Where("contract_id = ?", contractID).First(&existingServiceArea) - if err != nil { - return false, err - } + err := appCtx.DB().Where("zip3 = ?", originalZip).Where("contract_id = ?", contractID).First(&existingServiceArea) + if err != nil { + return false, err + } - err = appCtx.DB().Where("zip3 = ?", destinationZip).Where("contract_id = ?", contractID).First(&actualServiceArea) - if err != nil { - return false, err - } + err = appCtx.DB().Where("zip3 = ?", destinationZip).Where("contract_id = ?", contractID).First(&actualServiceArea) + if err != nil { + return false, err + } - if existingServiceArea.DomesticServiceAreaID != actualServiceArea.DomesticServiceAreaID { - return true, nil + if existingServiceArea.DomesticServiceAreaID != actualServiceArea.DomesticServiceAreaID { + return true, nil + } + return false, nil } - return false, nil } func (f *shipmentAddressUpdateRequester) doesDeliveryAddressUpdateChangeMileageBracket(appCtx appcontext.AppContext, originalPickupAddress models.Address, originalDeliveryAddress, newDeliveryAddress models.Address) (bool, error) { @@ -251,6 +281,7 @@ func (f *shipmentAddressUpdateRequester) RequestShipmentDeliveryAddressUpdate(ap if eTag != etag.GenerateEtag(shipment.UpdatedAt) { return nil, apperror.NewPreconditionFailedError(shipmentID, nil) } + isInternationalShipment := shipment.MarketCode == models.MarketCodeInternational shipmentHasApprovedDestSIT := f.doesShipmentContainApprovedDestinationSIT(shipment) @@ -333,12 +364,13 @@ func (f *shipmentAddressUpdateRequester) RequestShipmentDeliveryAddressUpdate(ap return nil, err } - updateNeedsTOOReview, err := f.doesDeliveryAddressUpdateChangeServiceArea(appCtx, contract.ID, addressUpdate.OriginalAddress, newAddress) + updateNeedsTOOReview, err := f.doesDeliveryAddressUpdateChangeServiceOrRateArea(appCtx, contract.ID, addressUpdate.OriginalAddress, newAddress, shipment) if err != nil { return nil, err } - if !updateNeedsTOOReview { + // international shipments don't need to be concerned with shorthaul/linehaul + if !updateNeedsTOOReview && !isInternationalShipment { if shipment.ShipmentType == models.MTOShipmentTypeHHG { updateNeedsTOOReview, err = f.doesDeliveryAddressUpdateChangeShipmentPricingType(*shipment.PickupAddress, addressUpdate.OriginalAddress, newAddress) if err != nil { @@ -354,7 +386,7 @@ func (f *shipmentAddressUpdateRequester) RequestShipmentDeliveryAddressUpdate(ap } } - if !updateNeedsTOOReview { + if !updateNeedsTOOReview && !isInternationalShipment { if shipment.ShipmentType == models.MTOShipmentTypeHHG { updateNeedsTOOReview, err = f.doesDeliveryAddressUpdateChangeMileageBracket(appCtx, *shipment.PickupAddress, addressUpdate.OriginalAddress, newAddress) if err != nil { @@ -371,7 +403,7 @@ func (f *shipmentAddressUpdateRequester) RequestShipmentDeliveryAddressUpdate(ap } if !updateNeedsTOOReview { - updateNeedsTOOReview, err = f.isAddressChangeDistanceOver50(appCtx, addressUpdate) + updateNeedsTOOReview, err = f.isAddressChangeDistanceOver50(appCtx, addressUpdate, isInternationalShipment) if err != nil { return nil, err } @@ -390,7 +422,7 @@ func (f *shipmentAddressUpdateRequester) RequestShipmentDeliveryAddressUpdate(ap return apperror.NewQueryError("ShipmentAddressUpdate", txnErr, "error saving shipment address update request") } - //Get the move + // Get the move var move models.Move err := txnAppCtx.DB().Find(&move, shipment.MoveTaskOrderID) if err != nil { @@ -463,6 +495,7 @@ func (f *shipmentAddressUpdateRequester) ReviewShipmentAddressChange(appCtx appc } shipment = addressUpdate.Shipment + isInternationalShipment := shipment.MarketCode == models.MarketCodeInternational if tooApprovalStatus == models.ShipmentAddressUpdateStatusApproved { queryBuilder := query.NewQueryBuilder() @@ -472,6 +505,7 @@ func (f *shipmentAddressUpdateRequester) ReviewShipmentAddressChange(appCtx appc addressUpdate.Status = models.ShipmentAddressUpdateStatusApproved addressUpdate.OfficeRemarks = &tooRemarks shipment.DestinationAddress = &addressUpdate.NewAddress + shipment.DestinationAddressID = &addressUpdate.NewAddressID var haulPricingTypeHasChanged bool if shipment.ShipmentType == models.MTOShipmentTypeHHG { @@ -499,7 +533,7 @@ func (f *shipmentAddressUpdateRequester) ReviewShipmentAddressChange(appCtx appc // If the pricing type has changed then we automatically reject the DLH or DSH service item on the shipment since it is now inaccurate var approvedPaymentRequestsExistsForServiceItem bool - if haulPricingTypeHasChanged && len(shipment.MTOServiceItems) > 0 { + if haulPricingTypeHasChanged && len(shipment.MTOServiceItems) > 0 && !isInternationalShipment { serviceItems := shipment.MTOServiceItems autoRejectionRemark := "Automatically rejected due to change in destination address affecting the ZIP code qualification for short haul / line haul." var regeneratedServiceItems models.MTOServiceItems @@ -605,7 +639,7 @@ func (f *shipmentAddressUpdateRequester) ReviewShipmentAddressChange(appCtx appc // if the shipment has an estimated weight, we need to update the service item pricing since we know the distances have changed // this only applies to international shipments that the TOO is approving the address change for if shipment.PrimeEstimatedWeight != nil && - shipment.MarketCode == models.MarketCodeInternational && + isInternationalShipment && tooApprovalStatus == models.ShipmentAddressUpdateStatusApproved { portZip, portType, err := models.GetPortLocationInfoForShipment(appCtx.DB(), shipment.ID) if err != nil { diff --git a/pkg/services/shipment_address_update/shipment_address_update_requester_test.go b/pkg/services/shipment_address_update/shipment_address_update_requester_test.go index 4330b13f9eb..b7579c61d16 100644 --- a/pkg/services/shipment_address_update/shipment_address_update_requester_test.go +++ b/pkg/services/shipment_address_update/shipment_address_update_requester_test.go @@ -66,7 +66,7 @@ func (suite *ShipmentAddressUpdateServiceSuite) TestCreateApprovedShipmentAddres moveRouter := moveservices.NewMoveRouter() addressUpdateRequester := NewShipmentAddressUpdateRequester(mockPlanner, addressCreator, moveRouter) - suite.Run("Successfully create ShipmentAddressUpdate", func() { + suite.Run("Successfully create ShipmentAddressUpdate for a domestic shipment", func() { mockPlanner.On("ZipTransitDistance", mock.AnythingOfType("*appcontext.appContext"), "90210", @@ -108,6 +108,142 @@ func (suite *ShipmentAddressUpdateServiceSuite) TestCreateApprovedShipmentAddres suite.Equal(newAddress.City, updatedShipment.DestinationAddress.City) }) + suite.Run("Successfully create ShipmentAddressUpdate for an international shipment that requires approval", func() { + mockPlanner.On("ZipTransitDistance", + mock.AnythingOfType("*appcontext.appContext"), + "90210", + "94535", + false, + false, + ).Return(2500, nil).Twice() + mockPlanner.On("ZipTransitDistance", + mock.AnythingOfType("*appcontext.appContext"), + "94535", + "94535", + false, + false, + ).Return(2500, nil).Once() + move := setupTestData() + + pickupAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Des Moines", + State: "IA", + PostalCode: "50314", + IsOconus: models.BoolPointer(false), + }, + }, + }, nil) + destinationAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "Anchorage", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + }, + }, nil) + shipment := factory.BuildMTOShipmentWithMove(&move, suite.DB(), []factory.Customization{ + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + PickupAddressID: &pickupAddress.ID, + DestinationAddressID: &destinationAddress.ID, + }, + }, + }, nil) + + newAddress := models.Address{ + StreetAddress1: "Colder Ave.", + City: "Klawock", + State: "AK", + PostalCode: "99925", + } + suite.NotEmpty(move.MTOShipments) + update, err := addressUpdateRequester.RequestShipmentDeliveryAddressUpdate(suite.AppContextForTest(), shipment.ID, newAddress, "we really need to change the address", etag.GenerateEtag(shipment.UpdatedAt)) + suite.NoError(err) + suite.NotNil(update) + suite.Equal(models.ShipmentAddressUpdateStatusRequested, update.Status) + + // Make sure the destination address on the shipment was NOT updated + var updatedShipment models.MTOShipment + err = suite.DB().EagerPreload("DestinationAddress").Find(&updatedShipment, shipment.ID) + suite.NoError(err) + + suite.NotEqual(newAddress.StreetAddress1, updatedShipment.DestinationAddress.StreetAddress1) + suite.NotEqual(newAddress.PostalCode, updatedShipment.DestinationAddress.PostalCode) + suite.NotEqual(newAddress.City, updatedShipment.DestinationAddress.City) + }) + + suite.Run("Successfully create ShipmentAddressUpdate for an international shipment that requires approval", func() { + mockPlanner.On("ZipTransitDistance", + mock.AnythingOfType("*appcontext.appContext"), + "99505", + "99506", + false, + true, + ).Return(49, nil) + move := setupTestData() + + pickupAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "Tester Address", + City: "Des Moines", + State: "IA", + PostalCode: "50314", + IsOconus: models.BoolPointer(false), + }, + }, + }, nil) + destinationAddress := factory.BuildAddress(suite.DB(), []factory.Customization{ + { + Model: models.Address{ + StreetAddress1: "JBER", + City: "Anchorage", + State: "AK", + PostalCode: "99505", + IsOconus: models.BoolPointer(true), + }, + }, + }, nil) + shipment := factory.BuildMTOShipmentWithMove(&move, suite.DB(), []factory.Customization{ + { + Model: models.MTOShipment{ + MarketCode: models.MarketCodeInternational, + PickupAddressID: &pickupAddress.ID, + DestinationAddressID: &destinationAddress.ID, + }, + }, + }, nil) + + // this shouldn't change the rate area + newAddress := models.Address{ + StreetAddress1: "Elsewhere Ave.", + City: "Anchorage", + State: "AK", + PostalCode: "99506", + } + suite.NotEmpty(move.MTOShipments) + update, err := addressUpdateRequester.RequestShipmentDeliveryAddressUpdate(suite.AppContextForTest(), shipment.ID, newAddress, "we really need to change the address", etag.GenerateEtag(shipment.UpdatedAt)) + suite.NoError(err) + suite.NotNil(update) + suite.Equal(models.ShipmentAddressUpdateStatusApproved, update.Status) + + // Make sure the destination address on the shipment was updated + var updatedShipment models.MTOShipment + err = suite.DB().EagerPreload("DestinationAddress").Find(&updatedShipment, shipment.ID) + suite.NoError(err) + + suite.Equal(newAddress.StreetAddress1, updatedShipment.DestinationAddress.StreetAddress1) + suite.Equal(newAddress.PostalCode, updatedShipment.DestinationAddress.PostalCode) + suite.Equal(newAddress.City, updatedShipment.DestinationAddress.City) + }) + suite.Run("Update with invalid etag should fail", func() { move := setupTestData() shipment := factory.BuildMTOShipmentWithMove(&move, suite.DB(), nil, nil) diff --git a/pkg/services/shipment_summary_worksheet/shipment_summary_worksheet.go b/pkg/services/shipment_summary_worksheet/shipment_summary_worksheet.go index 3818ef78a3f..63722c99422 100644 --- a/pkg/services/shipment_summary_worksheet/shipment_summary_worksheet.go +++ b/pkg/services/shipment_summary_worksheet/shipment_summary_worksheet.go @@ -208,7 +208,7 @@ func SSWGetEntitlement(grade internalmessages.OrderPayGrade, hasDependents bool, } // Calculates cost for the Remaining PPM Incentive (pre-tax) field on page 2 of SSW form. -func CalculateRemainingPPMEntitlement(finalIncentive *unit.Cents, sitMemberPaid float64, sitGTCCPaid float64, aoa *unit.Cents) float64 { +func CalculateRemainingPPMEntitlement(finalIncentive *unit.Cents, aoa *unit.Cents) float64 { // FinalIncentive var finalIncentiveFloat float64 = 0 if finalIncentive != nil { @@ -223,7 +223,7 @@ func CalculateRemainingPPMEntitlement(finalIncentive *unit.Cents, sitMemberPaid // This costing is computed by taking the Actual Obligations 100% GCC plus the // SIT cost calculated (if SIT was approved and accepted) minus any Advance // Operating Allowance (AOA) the customer identified as receiving in the Document upload process - return (finalIncentiveFloat + sitMemberPaid + sitGTCCPaid) - aoaFloat + return finalIncentiveFloat - aoaFloat } const ( @@ -328,7 +328,7 @@ func (s *SSWPPMComputer) FormatValuesShipmentSummaryWorksheetFormPage2(data mode if data.IsActualExpenseReimbursement { data.PPMRemainingEntitlement = 0.0 } else { - data.PPMRemainingEntitlement = CalculateRemainingPPMEntitlement(data.PPMShipment.FinalIncentive, expensesMap["StorageMemberPaid"], expensesMap["StorageGTCCPaid"], data.PPMShipment.AdvanceAmountReceived) + data.PPMRemainingEntitlement = CalculateRemainingPPMEntitlement(data.PPMShipment.FinalIncentive, data.PPMShipment.AdvanceAmountReceived) } page2.PPMRemainingEntitlement = FormatDollars(data.PPMRemainingEntitlement) @@ -336,7 +336,15 @@ func (s *SSWPPMComputer) FormatValuesShipmentSummaryWorksheetFormPage2(data mode if err != nil { return page2, err } - page2.Disbursement = formatDisbursement(expensesMap, data.PPMRemainingEntitlement) + var finalIncentiveFloat float64 = 0 + if data.PPMShipment.FinalIncentive != nil { + finalIncentiveFloat = float64(*data.PPMShipment.FinalIncentive) / 100 + } + var aoaFloat float64 = 0 + if data.PPMShipment.AdvanceAmountReceived != nil { + aoaFloat = float64(*data.PPMShipment.AdvanceAmountReceived) / 100 + } + page2.Disbursement = formatDisbursement(expensesMap, finalIncentiveFloat-aoaFloat) } else { page2.PreparationDate2 = formatAOADate(data.SignedCertifications, data.PPMShipment.ID) page2.Disbursement = "N/A" @@ -950,7 +958,7 @@ func formatDisbursement(expensesMap map[string]float64, ppmRemainingEntitlement disbursementGTCC = 0 } else { // Disbursement Member is remaining entitlement plus member SIT minus GTCC Disbursement, not less than 0. - disbursementMember = ppmRemainingEntitlement + expensesMap["StorageMemberPaid"] - disbursementGTCC + disbursementMember = ppmRemainingEntitlement + expensesMap["StorageMemberPaid"] } // Return formatted values in string diff --git a/pkg/services/shipment_summary_worksheet/shipment_summary_worksheet_test.go b/pkg/services/shipment_summary_worksheet/shipment_summary_worksheet_test.go index b2d985573ff..b94768aaf41 100644 --- a/pkg/services/shipment_summary_worksheet/shipment_summary_worksheet_test.go +++ b/pkg/services/shipment_summary_worksheet/shipment_summary_worksheet_test.go @@ -26,6 +26,11 @@ import ( "github.com/transcom/mymove/pkg/uploader" ) +// Helper function to format disbursement field for equal checks +var expectedDisbursementString = func(expectedGTCC int, expectedMember int) string { + return "GTCC: " + FormatDollars((models.CentPointer(unit.Cents(expectedGTCC)).ToMillicents().ToDollarFloat())) + "\nMember: " + FormatDollars(models.CentPointer(unit.Cents(expectedMember)).ToMillicents().ToDollarFloat()) +} + func (suite *ShipmentSummaryWorksheetServiceSuite) TestFetchDataShipmentSummaryWorksheet() { //advanceID, _ := uuid.NewV4() ordersType := internalmessages.OrdersTypePERMANENTCHANGEOFSTATION @@ -693,7 +698,8 @@ func (suite *ShipmentSummaryWorksheetServiceSuite) TestMemberPaidRemainingPPMEnt sswPPMComputer := NewSSWPPMComputer(mockPPMCloseoutFetcher) expensesMap := SubTotalExpenses(ssd.MovingExpenses) sswPage2, _ := sswPPMComputer.FormatValuesShipmentSummaryWorksheetFormPage2(ssd, true, expensesMap) - suite.Equal("$4.00", sswPage2.PPMRemainingEntitlement) + suite.Equal("$3.00", sswPage2.PPMRemainingEntitlement) + suite.Equal(expectedDisbursementString(0, 400), sswPage2.Disbursement) } func (suite *ShipmentSummaryWorksheetServiceSuite) TestAOAPacketPPMEntitlementFormatValuesShipmentSummaryWorksheetFormPage2() { @@ -777,7 +783,8 @@ func (suite *ShipmentSummaryWorksheetServiceSuite) TestNullCheckForFinalIncentiv sswPPMComputer := NewSSWPPMComputer(mockPPMCloseoutFetcher) expensesMap := SubTotalExpenses(ssd.MovingExpenses) sswPage2, _ := sswPPMComputer.FormatValuesShipmentSummaryWorksheetFormPage2(ssd, true, expensesMap) - suite.Equal("$1.00", sswPage2.PPMRemainingEntitlement) + suite.Equal("$0.00", sswPage2.PPMRemainingEntitlement) + suite.Equal(expectedDisbursementString(0, 100), sswPage2.Disbursement) } func (suite *ShipmentSummaryWorksheetServiceSuite) TestGTCCPaidRemainingPPMEntitlementFormatValuesShipmentSummaryWorksheetFormPage2() { @@ -827,7 +834,8 @@ func (suite *ShipmentSummaryWorksheetServiceSuite) TestGTCCPaidRemainingPPMEntit mockPPMCloseoutFetcher := &mocks.PPMCloseoutFetcher{} sswPPMComputer := NewSSWPPMComputer(mockPPMCloseoutFetcher) sswPage2, _ := sswPPMComputer.FormatValuesShipmentSummaryWorksheetFormPage2(ssd, true, expensesMap) - suite.Equal("$105.00", sswPage2.PPMRemainingEntitlement) + suite.Equal("$5.00", sswPage2.PPMRemainingEntitlement) + suite.Equal(expectedDisbursementString(500, 500), sswPage2.Disbursement) } func (suite *ShipmentSummaryWorksheetServiceSuite) TestGroupExpenses() { paidWithGTCC := false @@ -1320,11 +1328,6 @@ func (suite *ShipmentSummaryWorksheetServiceSuite) TestFillSSWPDFForm() { func (suite *ShipmentSummaryWorksheetServiceSuite) TestActualExpenseReimbursementCalculations() { - // Helper function to format disbursement field for equal checks - expectedDisbursementString := func(expectedGTCC int, expectedMember int) string { - return "GTCC: " + FormatDollars((models.CentPointer(unit.Cents(expectedGTCC)).ToMillicents().ToDollarFloat())) + "\nMember: " + FormatDollars(models.CentPointer(unit.Cents(expectedMember)).ToMillicents().ToDollarFloat()) - } - fakeS3 := storageTest.NewFakeS3Storage(true) userUploader, uploaderErr := uploader.NewUserUploader(fakeS3, 25*uploader.MB) suite.FatalNoError(uploaderErr) @@ -1950,23 +1953,23 @@ func (suite *ShipmentSummaryWorksheetServiceSuite) TestFormatDisbursement() { // Test case 1: GTCC calculation B is less than GTCC calculation A // Additionally, Member should not be less than 0 - expectedResult := "GTCC: " + FormatDollars(100.00) + "\nMember: " + FormatDollars(0) + expectedResult := "GTCC: " + FormatDollars(100.00) + "\nMember: " + FormatDollars(100.00) expensesMap["TotalGTCCPaid"] = 200.00 expensesMap["StorageGTCCPaid"] = 300.00 ppmRemainingEntitlement := 60.00 expensesMap["StorageMemberPaid"] = 40.00 result := formatDisbursement(expensesMap, ppmRemainingEntitlement) - suite.Equal(result, expectedResult) + suite.Equal(expectedResult, result) // Test case 2: GTCC calculation A is less than GTCC calculation B - expectedResult = "GTCC: " + FormatDollars(100.00) + "\nMember: " + FormatDollars(400.00) + expectedResult = "GTCC: " + FormatDollars(100.00) + "\nMember: " + FormatDollars(500.00) expensesMap = make(map[string]float64) expensesMap["TotalGTCCPaid"] = 60.00 expensesMap["StorageGTCCPaid"] = 40.00 ppmRemainingEntitlement = 300.00 expensesMap["StorageMemberPaid"] = 200.00 result = formatDisbursement(expensesMap, ppmRemainingEntitlement) - suite.Equal(result, expectedResult) + suite.Equal(expectedResult, result) // Test case 3: GTCC calculation is less than 0 expectedResult = "GTCC: " + FormatDollars(0) + "\nMember: " + FormatDollars(-250.00) @@ -1976,5 +1979,5 @@ func (suite *ShipmentSummaryWorksheetServiceSuite) TestFormatDisbursement() { ppmRemainingEntitlement = -300.00 expensesMap["StorageMemberPaid"] = 50.00 result = formatDisbursement(expensesMap, ppmRemainingEntitlement) - suite.Equal(result, expectedResult) + suite.Equal(expectedResult, result) } diff --git a/src/components/Office/AddOrdersForm/AddOrdersForm.jsx b/src/components/Office/AddOrdersForm/AddOrdersForm.jsx index ca0b173a684..113d8761284 100644 --- a/src/components/Office/AddOrdersForm/AddOrdersForm.jsx +++ b/src/components/Office/AddOrdersForm/AddOrdersForm.jsx @@ -13,7 +13,7 @@ import ToolTip from 'shared/ToolTip/ToolTip'; import { DatePickerInput, DropdownInput, DutyLocationInput } from 'components/form/fields'; import { Form } from 'components/form/Form'; import SectionWrapper from 'components/Customer/SectionWrapper'; -import { ORDERS_PAY_GRADE_OPTIONS } from 'constants/orders'; +import { ORDERS_PAY_GRADE_OPTIONS, ORDERS_TYPE } from 'constants/orders'; import { dropdownInputOptions } from 'utils/formatters'; import WizardNavigation from 'components/Customer/WizardNavigation/WizardNavigation'; import Callout from 'components/Callout'; @@ -39,6 +39,9 @@ const AddOrdersForm = ({ const [hasDependents, setHasDependents] = useState(false); const [isOconusMove, setIsOconusMove] = useState(false); const [enableUB, setEnableUB] = useState(false); + const [isHasDependentsDisabled, setHasDependentsDisabled] = useState(false); + const [prevOrderType, setPrevOrderType] = useState(''); + const [filteredOrderTypeOptions, setFilteredOrderTypeOptions] = useState(ordersTypeOptions); const validationSchema = Yup.object().shape({ ordersType: Yup.mixed() @@ -95,9 +98,24 @@ const AddOrdersForm = ({ } }, [currentDutyLocation, newDutyLocation, isOconusMove, hasDependents, enableUB]); + useEffect(() => { + const fetchData = async () => { + const alaskaEnabled = await isBooleanFlagEnabled(FEATURE_FLAG_KEYS.ENABLE_ALASKA); + + const updatedOptions = alaskaEnabled + ? ordersTypeOptions + : ordersTypeOptions.filter( + (e) => e.key !== ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS && e.key !== ORDERS_TYPE.STUDENT_TRAVEL, + ); + + setFilteredOrderTypeOptions(updatedOptions); + }; + fetchData(); + }, [ordersTypeOptions]); + return ( - {({ values, isValid, isSubmitting, handleSubmit, touched, setFieldValue }) => { + {({ values, isValid, isSubmitting, handleSubmit, handleChange, touched, setFieldValue }) => { const isRetirementOrSeparation = ['RETIREMENT', 'SEPARATION'].includes(values.ordersType); if (!values.origin_duty_location && touched.origin_duty_location) originMeta = 'Required'; else originMeta = null; @@ -107,17 +125,37 @@ const AddOrdersForm = ({ const handleHasDependentsChange = (e) => { // Declare a duplicate local scope of the field value // for the form to prevent state race conditions - const fieldValueHasDependents = e.target.value === 'yes'; - setHasDependents(e.target.value === 'yes'); - setFieldValue('hasDependents', fieldValueHasDependents ? 'yes' : 'no'); - if (fieldValueHasDependents && isOconusMove && enableUB) { - setShowAccompaniedTourField(true); - setShowDependentAgeFields(true); + if (e.target.value === '') { + setFieldValue('hasDependents', ''); } else { - setShowAccompaniedTourField(false); - setShowDependentAgeFields(false); + const fieldValueHasDependents = e.target.value === 'yes'; + setHasDependents(e.target.value === 'yes'); + setFieldValue('hasDependents', fieldValueHasDependents ? 'yes' : 'no'); + if (fieldValueHasDependents && isOconusMove && enableUB) { + setShowAccompaniedTourField(true); + setShowDependentAgeFields(true); + } else { + setShowAccompaniedTourField(false); + setShowDependentAgeFields(false); + } } }; + const handleOrderTypeChange = (e) => { + const { value } = e.target; + if (value === ORDERS_TYPE.STUDENT_TRAVEL || value === ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS) { + setHasDependentsDisabled(true); + handleHasDependentsChange({ target: { value: 'yes' } }); + } else { + setHasDependentsDisabled(false); + if ( + prevOrderType === ORDERS_TYPE.STUDENT_TRAVEL || + prevOrderType === ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS + ) { + handleHasDependentsChange({ target: { value: '' } }); + } + } + setPrevOrderType(value); + }; return (
@@ -127,8 +165,12 @@ const AddOrdersForm = ({ { + handleChange(e); + handleOrderTypeChange(e); + }} isDisabled={isSafetyMoveSelected || isBluebarkMoveSelected} /> @@ -206,6 +248,7 @@ const AddOrdersForm = ({ onChange={(e) => { handleHasDependentsChange(e); }} + disabled={isHasDependentsDisabled} /> { handleHasDependentsChange(e); }} + disabled={isHasDependentsDisabled} /> diff --git a/src/components/Office/AddOrdersForm/AddOrdersForm.test.jsx b/src/components/Office/AddOrdersForm/AddOrdersForm.test.jsx index 611331d78a9..a374c86022b 100644 --- a/src/components/Office/AddOrdersForm/AddOrdersForm.test.jsx +++ b/src/components/Office/AddOrdersForm/AddOrdersForm.test.jsx @@ -6,7 +6,7 @@ import { Provider } from 'react-redux'; import AddOrdersForm from './AddOrdersForm'; import { dropdownInputOptions } from 'utils/formatters'; -import { ORDERS_TYPE_OPTIONS } from 'constants/orders'; +import { ORDERS_TYPE, ORDERS_TYPE_OPTIONS } from 'constants/orders'; import { configureStore } from 'shared/store'; import { isBooleanFlagEnabled } from 'utils/featureFlags'; @@ -126,6 +126,40 @@ describe('CreateMoveCustomerInfo Component', () => { }); }); + it('renders each option for orders type', async () => { + isBooleanFlagEnabled.mockImplementation(() => Promise.resolve(true)); + + const { getByLabelText } = render( + + + , + ); + + const ordersTypeDropdown = getByLabelText('Orders type'); + expect(ordersTypeDropdown).toBeInstanceOf(HTMLSelectElement); + + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION); + expect(ordersTypeDropdown).toHaveValue(ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION); + + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.LOCAL_MOVE); + expect(ordersTypeDropdown).toHaveValue(ORDERS_TYPE.LOCAL_MOVE); + + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.RETIREMENT); + expect(ordersTypeDropdown).toHaveValue(ORDERS_TYPE.RETIREMENT); + + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.SEPARATION); + expect(ordersTypeDropdown).toHaveValue(ORDERS_TYPE.SEPARATION); + + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.TEMPORARY_DUTY); + expect(ordersTypeDropdown).toHaveValue(ORDERS_TYPE.TEMPORARY_DUTY); + + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS); + expect(ordersTypeDropdown).toHaveValue(ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS); + + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.STUDENT_TRAVEL); + expect(ordersTypeDropdown).toHaveValue(ORDERS_TYPE.STUDENT_TRAVEL); + }); + it('shows an error message if trying to submit an invalid form', async () => { const { getByRole, findAllByRole, getByLabelText } = render( @@ -194,6 +228,144 @@ describe('AddOrdersForm - OCONUS and Accompanied Tour Test', () => { }); }); }); + +describe('AddOrdersForm - Student Travel, Early Return of Dependents Test', () => { + it('has dependents is yes and disabled when order type is student travel', async () => { + isBooleanFlagEnabled.mockImplementation(() => Promise.resolve(true)); + + render( + + + , + ); + + await userEvent.selectOptions(screen.getByLabelText('Orders type'), ORDERS_TYPE.STUDENT_TRAVEL); + + const hasDependentsYes = screen.getByLabelText('Yes'); + const hasDependentsNo = screen.getByLabelText('No'); + + await waitFor(() => { + expect(hasDependentsYes).toBeChecked(); + expect(hasDependentsYes).toBeDisabled(); + expect(hasDependentsNo).toBeDisabled(); + }); + }); + + it('has dependents is yes and disabled when order type is early return', async () => { + isBooleanFlagEnabled.mockImplementation(() => Promise.resolve(true)); + + render( + + + , + ); + + await userEvent.selectOptions(screen.getByLabelText('Orders type'), ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS); + const hasDependentsYes = screen.getByLabelText('Yes'); + const hasDependentsNo = screen.getByLabelText('No'); + + await waitFor(() => { + expect(hasDependentsYes).toBeChecked(); + expect(hasDependentsYes).toBeDisabled(); + expect(hasDependentsNo).toBeDisabled(); + }); + }); + + it('has dependents becomes disabled and then re-enabled for order type student travel', async () => { + isBooleanFlagEnabled.mockImplementation(() => Promise.resolve(true)); + + render( + + + , + ); + + await userEvent.selectOptions(screen.getByLabelText('Orders type'), ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION); + + const hasDependentsYesPermChg = screen.getByLabelText('Yes'); + const hasDependentsNoPermChg = screen.getByLabelText('No'); + + await waitFor(() => { + expect(hasDependentsYesPermChg).not.toBeChecked(); + expect(hasDependentsYesPermChg).toBeEnabled(); + expect(hasDependentsNoPermChg).not.toBeChecked(); + expect(hasDependentsNoPermChg).toBeEnabled(); + }); + + // set order type to value that disables and defaults "has dependents" + await userEvent.selectOptions(screen.getByLabelText('Orders type'), ORDERS_TYPE.STUDENT_TRAVEL); + + const hasDependentsYesStudent = screen.getByLabelText('Yes'); + const hasDependentsNoStudent = screen.getByLabelText('No'); + + await waitFor(() => { + expect(hasDependentsYesStudent).toBeChecked(); + expect(hasDependentsYesStudent).toBeDisabled(); + expect(hasDependentsNoStudent).toBeDisabled(); + }); + + // set order type to value the re-enables "has dependents" + await userEvent.selectOptions(screen.getByLabelText('Orders type'), ORDERS_TYPE.LOCAL_MOVE); + + const hasDependentsYesLocalMove = screen.getByLabelText('Yes'); + const hasDependentsNoLocalMove = screen.getByLabelText('No'); + + await waitFor(() => { + expect(hasDependentsYesLocalMove).not.toBeChecked(); + expect(hasDependentsYesLocalMove).toBeEnabled(); + expect(hasDependentsNoLocalMove).not.toBeChecked(); + expect(hasDependentsNoLocalMove).toBeEnabled(); + }); + }); + + it('has dependents becomes disabled and then re-enabled for order type early return', async () => { + isBooleanFlagEnabled.mockImplementation(() => Promise.resolve(true)); + + render( + + + , + ); + + await userEvent.selectOptions(screen.getByLabelText('Orders type'), ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION); + + const hasDependentsYesPermChg = screen.getByLabelText('Yes'); + const hasDependentsNoPermChg = screen.getByLabelText('No'); + + await waitFor(() => { + expect(hasDependentsYesPermChg).not.toBeChecked(); + expect(hasDependentsYesPermChg).toBeEnabled(); + expect(hasDependentsNoPermChg).not.toBeChecked(); + expect(hasDependentsNoPermChg).toBeEnabled(); + }); + + // set order type to value that disables and defaults "has dependents" + await userEvent.selectOptions(screen.getByLabelText('Orders type'), ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS); + + const hasDependentsYesEarly = screen.getByLabelText('Yes'); + const hasDependentsNoEarly = screen.getByLabelText('No'); + + await waitFor(() => { + expect(hasDependentsYesEarly).toBeChecked(); + expect(hasDependentsYesEarly).toBeDisabled(); + expect(hasDependentsNoEarly).toBeDisabled(); + }); + + // set order type to value the re-enables "has dependents" + await userEvent.selectOptions(screen.getByLabelText('Orders type'), ORDERS_TYPE.LOCAL_MOVE); + + const hasDependentsYesLocalMove = screen.getByLabelText('Yes'); + const hasDependentsNoLocalMove = screen.getByLabelText('No'); + + await waitFor(() => { + expect(hasDependentsYesLocalMove).not.toBeChecked(); + expect(hasDependentsYesLocalMove).toBeEnabled(); + expect(hasDependentsNoLocalMove).not.toBeChecked(); + expect(hasDependentsNoLocalMove).toBeEnabled(); + }); + }); +}); + describe('AddOrdersForm - Edge Cases and Additional Scenarios', () => { it('disables orders type when safety move is selected', async () => { render( diff --git a/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.jsx b/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.jsx index 74a2ac1e06b..dc7e9d3a9d4 100644 --- a/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.jsx +++ b/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.jsx @@ -9,23 +9,35 @@ import DataTable from 'components/DataTable/index'; import { formatTwoLineAddress } from 'utils/shipmentDisplay'; import DataTableWrapper from 'components/DataTableWrapper'; import { ShipmentAddressUpdateShape } from 'types'; +import { MARKET_CODES } from 'shared/constants'; -const AddressUpdatePreview = ({ deliveryAddressUpdate }) => { +const AddressUpdatePreview = ({ deliveryAddressUpdate, shipment }) => { const { originalAddress, newAddress, contractorRemarks } = deliveryAddressUpdate; const newSitMileage = deliveryAddressUpdate.newSitDistanceBetween; + const { marketCode } = shipment; return (

Delivery Address

- - If approved, the requested update to the delivery address will change one or all of the following: - Service area. - Mileage bracket for direct delivery. - - ZIP3 resulting in Domestic Shorthaul (DSH) changing to Domestic Linehaul (DLH) or vice versa. + {marketCode === MARKET_CODES.DOMESTIC ? ( + + If approved, the requested update to the delivery address will change one or all of the following: + Service area. + Mileage bracket for direct delivery. + + ZIP3 resulting in Domestic Shorthaul (DSH) changing to Domestic Linehaul (DLH) or vice versa. + + Approvals will result in updated pricing for this shipment. Customer may be subject to excess costs. + + ) : ( + + If approved, the requested update to the delivery address will change one or all of the following: + The rate area for the international shipment destination address. + Pricing for international shipping & linehaul. + Pricing for POD Fuel Surcharge (if applicable). + Approvals will result in updated pricing for this shipment. Customer may be subject to excess costs. - Approvals will result in updated pricing for this shipment. Customer may be subject to excess costs. - + )} {newSitMileage > 50 ? ( diff --git a/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.stories.jsx b/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.stories.jsx index a77d9fcc672..46273140dca 100644 --- a/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.stories.jsx +++ b/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.stories.jsx @@ -2,6 +2,8 @@ import React from 'react'; import AddressUpdatePreview from './AddressUpdatePreview'; +import { MARKET_CODES } from 'shared/constants'; + const mockDeliveryAddressUpdate = { contractorRemarks: 'Test Contractor Remark', id: 'c49f7921-5a6e-46b4-bb39-022583574453', @@ -26,6 +28,10 @@ const mockDeliveryAddressUpdate = { status: 'REQUESTED', }; +const mockShipment = { + marketCode: MARKET_CODES.DOMESTIC, +}; + const destinationSITServiceItems = ['DDDSIT', 'DDFSIT', 'DDASIT', 'DDSFSC']; export default { @@ -39,6 +45,7 @@ export const preview = { ,
diff --git a/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.test.jsx b/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.test.jsx index c8578937e06..d95d3d0fef3 100644 --- a/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.test.jsx +++ b/src/components/Office/AddressUpdatePreview/AddressUpdatePreview.test.jsx @@ -3,6 +3,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import AddressUpdatePreview from './AddressUpdatePreview'; +import { MARKET_CODES } from 'shared/constants'; + const mockDeliveryAddressUpdateWithoutSIT = { contractorRemarks: 'Test Contractor Remark', id: 'c49f7921-5a6e-46b4-bb39-022583574453', @@ -66,9 +68,53 @@ const mockDeliveryAddressUpdateWithSIT = { }, status: 'REQUESTED', }; + +const domesticShipment = { + marketCode: MARKET_CODES.DOMESTIC, +}; + +const internationalShipment = { + marketCode: MARKET_CODES.INTERNATIONAL, +}; describe('AddressUpdatePreview', () => { - it('renders all of the address preview information', async () => { - render(); + it('renders all of the address preview information for an international shipment', async () => { + render( + , + ); + // Heading and alert present + expect(screen.getByRole('heading', { name: 'Delivery Address' })).toBeInTheDocument(); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert')).toHaveTextContent( + 'If approved, the requested update to the delivery address will change one or all of the following:' + + 'The rate area for the international shipment destination address.' + + 'Pricing for international shipping & linehaul.' + + 'Pricing for POD Fuel Surcharge (if applicable).' + + 'Approvals will result in updated pricing for this shipment. Customer may be subject to excess costs.', + ); + const addressChangePreview = screen.getByTestId('address-change-preview'); + expect(addressChangePreview).toBeInTheDocument(); + const addresses = screen.getAllByTestId('two-line-address'); + expect(addresses).toHaveLength(2); + // Original Address + expect(addressChangePreview).toHaveTextContent('Original Delivery Address'); + expect(addresses[0]).toHaveTextContent('987 Any Avenue'); + expect(addresses[0]).toHaveTextContent('Fairfield, CA 94535'); + // New Address + expect(addressChangePreview).toHaveTextContent('Requested Delivery Address'); + expect(addresses[1]).toHaveTextContent('123 Any Street'); + expect(addresses[1]).toHaveTextContent('Beverly Hills, CA 90210'); + // Request details (contractor remarks) + expect(addressChangePreview).toHaveTextContent('Update request details'); + expect(addressChangePreview).toHaveTextContent('Contractor remarks: Test Contractor Remark'); + }); + + it('renders all of the address preview information for a domestic shipment', async () => { + render( + , + ); // Heading and alert present expect(screen.getByRole('heading', { name: 'Delivery Address' })).toBeInTheDocument(); expect(screen.getByTestId('alert')).toBeInTheDocument(); @@ -102,8 +148,11 @@ describe('AddressUpdatePreview', () => { expect(screen.queryByTestId('destSitAlert')).not.toBeInTheDocument(); }); }); + it('renders the destination SIT alert when shipment contains dest SIT service items', () => { - render(); + render( + , + ); // Heading and alert present expect(screen.getByRole('heading', { name: 'Delivery Address' })).toBeInTheDocument(); expect(screen.getByTestId('destSitAlert')).toBeInTheDocument(); diff --git a/src/components/Office/ShipmentAddressUpdateReviewRequestModal/ShipmentAddressUpdateReviewRequestModal.jsx b/src/components/Office/ShipmentAddressUpdateReviewRequestModal/ShipmentAddressUpdateReviewRequestModal.jsx index 4325db1565f..cf1575395de 100644 --- a/src/components/Office/ShipmentAddressUpdateReviewRequestModal/ShipmentAddressUpdateReviewRequestModal.jsx +++ b/src/components/Office/ShipmentAddressUpdateReviewRequestModal/ShipmentAddressUpdateReviewRequestModal.jsx @@ -60,10 +60,7 @@ export const ShipmentAddressUpdateReviewRequestModal = ({ return (
- +

Review Request

diff --git a/src/components/Office/ShipmentForm/ShipmentForm.jsx b/src/components/Office/ShipmentForm/ShipmentForm.jsx index f4b0a723828..076212d6953 100644 --- a/src/components/Office/ShipmentForm/ShipmentForm.jsx +++ b/src/components/Office/ShipmentForm/ShipmentForm.jsx @@ -788,6 +788,7 @@ const ShipmentForm = (props) => { if (status === ADDRESS_UPDATE_STATUS.APPROVED) { setValues({ ...values, + hasDeliveryAddress: 'yes', delivery: { ...values.delivery, address: mtoShipment.deliveryAddressUpdate.newAddress, diff --git a/src/constants/MoveHistory/Database/Tables.js b/src/constants/MoveHistory/Database/Tables.js index e097f569adf..f860416dda7 100644 --- a/src/constants/MoveHistory/Database/Tables.js +++ b/src/constants/MoveHistory/Database/Tables.js @@ -19,4 +19,5 @@ export default { moving_expenses: 'moving_expenses', progear_weight_tickets: 'progear_weight_tickets', gsr_appeals: 'gsr_appeals', + shipment_address_updates: 'shipment_address_updates', }; diff --git a/src/constants/MoveHistory/EventTemplates/ReviewShipmentAddressUpdate/reviewShipmentAddressUpdate.jsx b/src/constants/MoveHistory/EventTemplates/ReviewShipmentAddressUpdate/reviewShipmentAddressUpdate.jsx new file mode 100644 index 00000000000..3597791218a --- /dev/null +++ b/src/constants/MoveHistory/EventTemplates/ReviewShipmentAddressUpdate/reviewShipmentAddressUpdate.jsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import o from 'constants/MoveHistory/UIDisplay/Operations'; +import a from 'constants/MoveHistory/Database/Actions'; +import t from 'constants/MoveHistory/Database/Tables'; + +export default { + action: a.UPDATE, + eventName: o.reviewShipmentAddressUpdate, + tableName: t.shipment_address_updates, + getEventNameDisplay: () => { + return 'Shipment Destination Address Request'; + }, + getDetails: ({ changedValues }) => { + if (changedValues.status === 'APPROVED') { + return ( +
+ Status: Approved +
+ ); + } + if (changedValues.status === 'REJECTED') { + return ( +
+ Status: Rejected +
+ ); + } + return null; + }, +}; diff --git a/src/constants/MoveHistory/EventTemplates/ReviewShipmentAddressUpdate/reviewShipmentAddressUpdate.test.jsx b/src/constants/MoveHistory/EventTemplates/ReviewShipmentAddressUpdate/reviewShipmentAddressUpdate.test.jsx new file mode 100644 index 00000000000..8077e4f324e --- /dev/null +++ b/src/constants/MoveHistory/EventTemplates/ReviewShipmentAddressUpdate/reviewShipmentAddressUpdate.test.jsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react'; + +import getTemplate from 'constants/MoveHistory/TemplateManager'; +import o from 'constants/MoveHistory/UIDisplay/Operations'; +import a from 'constants/MoveHistory/Database/Actions'; +import t from 'constants/MoveHistory/Database/Tables'; +import e from 'constants/MoveHistory/EventTemplates/ReviewShipmentAddressUpdate/reviewShipmentAddressUpdate'; + +describe('when given a Review Shipment Address Update history record', () => { + const context = [ + { + name: 'Shipment', + }, + ]; + + it('displays the correct event name', () => { + const historyRecord = { + action: a.UPDATE, + changedValues: { + status: 'APPROVED', + }, + context, + eventName: o.reviewShipmentAddressUpdate, + tableName: t.shipment_address_updates, + }; + + const template = getTemplate(historyRecord); + expect(template).toMatchObject(e); + expect(template.getEventNameDisplay(historyRecord)).toEqual('Shipment Destination Address Request'); + }); + + it('displays the status as "Approved"', () => { + const historyRecord = { + action: a.UPDATE, + changedValues: { + status: 'APPROVED', + }, + context, + eventName: o.reviewShipmentAddressUpdate, + tableName: t.shipment_address_updates, + }; + + const template = getTemplate(historyRecord); + + render(template.getDetails(historyRecord)); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText(/Approved/)).toBeInTheDocument(); + }); + + it('displays the status as "Rejected"', () => { + const historyRecord = { + action: a.UPDATE, + changedValues: { + status: 'REJECTED', + }, + context, + eventName: o.reviewShipmentAddressUpdate, + tableName: t.shipment_address_updates, + }; + + const template = getTemplate(historyRecord); + + render(template.getDetails(historyRecord)); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText(/Rejected/)).toBeInTheDocument(); + }); + + it('returns null if the status is not "APPROVED" or "REJECTED"', () => { + const historyRecord = { + action: a.UPDATE, + changedValues: { + status: 'PENDING', + }, + context, + eventName: o.reviewShipmentAddressUpdate, + tableName: t.shipment_address_updates, + }; + + const template = getTemplate(historyRecord); + expect(template.getDetails(historyRecord)).toBeNull(); + }); +}); diff --git a/src/constants/MoveHistory/EventTemplates/UpdateMTOServiceItem/updateServiceItemPricingAndWeights.jsx b/src/constants/MoveHistory/EventTemplates/UpdateMTOServiceItem/updateServiceItemPricingAndWeights.jsx new file mode 100644 index 00000000000..1397c699d7b --- /dev/null +++ b/src/constants/MoveHistory/EventTemplates/UpdateMTOServiceItem/updateServiceItemPricingAndWeights.jsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import o from 'constants/MoveHistory/UIDisplay/Operations'; +import a from 'constants/MoveHistory/Database/Actions'; +import t from 'constants/MoveHistory/Database/Tables'; +import { formatCents, formatWeight } from 'utils/formatters'; + +export default { + action: a.UPDATE, + eventName: o.updateMTOShipment, + tableName: t.mto_service_items, + getEventNameDisplay: ({ changedValues }) => { + if (changedValues.pricing_estimate) { + return 'Service item estimated price updated'; + } + if (changedValues.estimated_weight) { + return 'Service item estimated weight updated'; + } + return 'Service item updated'; + }, + getDetails: ({ changedValues, context }) => { + if (changedValues.pricing_estimate) { + return ( +
+ Service item: {context[0].name} +
+ Estimated Price: ${formatCents(changedValues.pricing_estimate)} +
+ ); + } + if (changedValues.estimated_weight) { + return ( +
+ Service item: {context[0].name} +
+ Estimated weight: {formatWeight(changedValues.estimated_weight)} +
+ ); + } + return null; + }, +}; diff --git a/src/constants/MoveHistory/EventTemplates/UpdateMTOServiceItem/updateServiceItemPricingAndWeights.test.jsx b/src/constants/MoveHistory/EventTemplates/UpdateMTOServiceItem/updateServiceItemPricingAndWeights.test.jsx new file mode 100644 index 00000000000..eae1e8fe630 --- /dev/null +++ b/src/constants/MoveHistory/EventTemplates/UpdateMTOServiceItem/updateServiceItemPricingAndWeights.test.jsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react'; + +import getTemplate from 'constants/MoveHistory/TemplateManager'; +import o from 'constants/MoveHistory/UIDisplay/Operations'; +import a from 'constants/MoveHistory/Database/Actions'; +import t from 'constants/MoveHistory/Database/Tables'; + +describe('when given an UpdateMTOServiceItem history record with pricing/weight changes', () => { + const context = [ + { + name: 'International Shipping & Linehaul', + shipment_type: 'HHG', + shipment_locator: 'RQ38D4-01', + shipment_id_abbr: 'a1b2c', + }, + ]; + + it('correctly matches the service item price update event', () => { + const historyRecord = { + action: a.UPDATE, + changedValues: { + pricing_estimate: 150000, + }, + context, + eventName: o.updateMTOShipment, + tableName: t.mto_service_items, + }; + + const template = getTemplate(historyRecord); + expect(template.getEventNameDisplay(historyRecord)).toEqual('Service item estimated price updated'); + + render(template.getDetails(historyRecord)); + expect(screen.getByText('Service item')).toBeInTheDocument(); + expect(screen.getByText(/International Shipping & Linehaul/)).toBeInTheDocument(); + expect(screen.getByText('Estimated Price')).toBeInTheDocument(); + expect(screen.getByText(/\$1,500\.00/)).toBeInTheDocument(); + }); + + it('correctly matches the service item weight update event', () => { + const historyRecord = { + action: a.UPDATE, + changedValues: { + estimated_weight: 1000, + }, + context, + eventName: o.updateMTOShipment, + tableName: t.mto_service_items, + }; + + const template = getTemplate(historyRecord); + expect(template.getEventNameDisplay(historyRecord)).toEqual('Service item estimated weight updated'); + + render(template.getDetails(historyRecord)); + expect(screen.getByText('Service item')).toBeInTheDocument(); + expect(screen.getByText(/International Shipping & Linehaul/)).toBeInTheDocument(); + expect(screen.getByText('Estimated weight')).toBeInTheDocument(); + expect(screen.getByText(/1,000 lbs/)).toBeInTheDocument(); + }); + + it('returns "Service item updated" for unknown changes', () => { + const historyRecord = { + action: a.UPDATE, + changedValues: { + unknownField: 'some value', + }, + context, + eventName: o.updateMTOShipment, + tableName: t.mto_service_items, + }; + + const template = getTemplate(historyRecord); + expect(template.getEventNameDisplay(historyRecord)).toEqual('Service item updated'); + expect(template.getDetails(historyRecord)).toBeNull(); + }); +}); diff --git a/src/constants/MoveHistory/EventTemplates/index.js b/src/constants/MoveHistory/EventTemplates/index.js index ed6d2a1529d..249eaf2dad3 100644 --- a/src/constants/MoveHistory/EventTemplates/index.js +++ b/src/constants/MoveHistory/EventTemplates/index.js @@ -30,6 +30,7 @@ export { default as updateBillableWeightAsTIO } from './UpdateBillableWeight/upd export { default as updateBillableWeightRemarksAsTIO } from './UpdateBillableWeight/updateBillableWeightRemarksAsTIO'; export { default as updateMoveTaskOrderStatus } from './UpdateMoveTaskOrderStatus/updateMoveTaskOrderStatus'; export { default as updateMTOServiceItem } from './UpdateMTOServiceItem/updateMTOServiceItem'; +export { default as updateServiceItemPricingAndWeights } from './UpdateMTOServiceItem/updateServiceItemPricingAndWeights'; export { default as updateMTOShipment } from './UpdateMTOShipment/updateMTOShipment'; export { default as updateMTOShipmentAgent } from './UpdateMTOShipment/updateMTOShipmentAgent'; export { default as updateMTOShipmentDeprecatePaymentRequest } from './UpdateMTOShipment/updateMTOShipmentDeprecatePaymentRequest'; @@ -110,3 +111,4 @@ export { default as moveCancelerPPMShipments } from './MoveCanceler/MoveCanceler export { default as cancelMove } from './CancelMove/CancelMove'; export { default as cancelMoveMTOShipments } from './CancelMove/CancelMoveMTOShipments'; export { default as cancelMovePPMShipments } from './CancelMove/CancelMovePPMShipments'; +export { default as reviewShipmentAddressUpdate } from './ReviewShipmentAddressUpdate/reviewShipmentAddressUpdate'; diff --git a/src/constants/MoveHistory/UIDisplay/Operations.js b/src/constants/MoveHistory/UIDisplay/Operations.js index 9e495ba55f5..a74bccc3603 100644 --- a/src/constants/MoveHistory/UIDisplay/Operations.js +++ b/src/constants/MoveHistory/UIDisplay/Operations.js @@ -66,4 +66,5 @@ export default { addAppealToViolation: 'addAppealToViolation', // ghc.yaml addAppealToSeriousIncident: 'addAppealToSeriousIncident', // ghc.yaml cancelMove: 'cancelMove', // internal.yaml + reviewShipmentAddressUpdate: 'reviewShipmentAddressUpdate', // ghc.yaml }; diff --git a/src/pages/Office/ServicesCounselingOrders/ServicesCounselingOrders.jsx b/src/pages/Office/ServicesCounselingOrders/ServicesCounselingOrders.jsx index 9a4b611f0de..5a3d37c59e0 100644 --- a/src/pages/Office/ServicesCounselingOrders/ServicesCounselingOrders.jsx +++ b/src/pages/Office/ServicesCounselingOrders/ServicesCounselingOrders.jsx @@ -1,5 +1,5 @@ /* eslint-disable camelcase */ -import React, { useEffect, useReducer } from 'react'; +import React, { useEffect, useReducer, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { Button } from '@trussworks/react-uswds'; import { Formik } from 'formik'; @@ -8,27 +8,32 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import ordersFormValidationSchema from '../Orders/ordersFormValidationSchema'; +import { isBooleanFlagEnabled } from 'utils/featureFlags'; import styles from 'styles/documentViewerWithSidebar.module.scss'; import { milmoveLogger } from 'utils/milmoveLog'; import OrdersDetailForm from 'components/Office/OrdersDetailForm/OrdersDetailForm'; import { DEPARTMENT_INDICATOR_OPTIONS } from 'constants/departmentIndicators'; -import { ORDERS_TYPE_DETAILS_OPTIONS, ORDERS_TYPE_OPTIONS, ORDERS_PAY_GRADE_OPTIONS } from 'constants/orders'; +import { + ORDERS_TYPE_DETAILS_OPTIONS, + ORDERS_TYPE_OPTIONS, + ORDERS_PAY_GRADE_OPTIONS, + ORDERS_TYPE, +} from 'constants/orders'; import { ORDERS } from 'constants/queryKeys'; import { servicesCounselingRoutes } from 'constants/routes'; import { useOrdersDocumentQueries } from 'hooks/queries'; import { getTacValid, getLoa, counselingUpdateOrder, getOrder } from 'services/ghcApi'; -import { formatSwaggerDate, dropdownInputOptions } from 'utils/formatters'; +import { formatSwaggerDate, dropdownInputOptions, formatYesNoAPIValue } from 'utils/formatters'; import LoadingPlaceholder from 'shared/LoadingPlaceholder'; import SomethingWentWrong from 'shared/SomethingWentWrong'; import { LineOfAccountingDfasElementOrder } from 'types/lineOfAccounting'; import { LOA_VALIDATION_ACTIONS, reducer as loaReducer, initialState as initialLoaState } from 'reducers/loaValidation'; import { TAC_VALIDATION_ACTIONS, reducer as tacReducer, initialState as initialTacState } from 'reducers/tacValidation'; -import { LOA_TYPE, MOVE_DOCUMENT_TYPE } from 'shared/constants'; +import { LOA_TYPE, MOVE_DOCUMENT_TYPE, FEATURE_FLAG_KEYS } from 'shared/constants'; import DocumentViewerFileManager from 'components/DocumentViewerFileManager/DocumentViewerFileManager'; import { scrollToViewFormikError } from 'utils/validation'; const deptIndicatorDropdownOptions = dropdownInputOptions(DEPARTMENT_INDICATOR_OPTIONS); -const ordersTypeDropdownOptions = dropdownInputOptions(ORDERS_TYPE_OPTIONS); const ordersTypeDetailsDropdownOptions = dropdownInputOptions(ORDERS_TYPE_DETAILS_OPTIONS); const payGradeDropdownOptions = dropdownInputOptions(ORDERS_PAY_GRADE_OPTIONS); @@ -39,8 +44,10 @@ const ServicesCounselingOrders = ({ files, amendedDocumentId, updateAmendedDocum const [tacValidationState, tacValidationDispatch] = useReducer(tacReducer, null, initialTacState); const [loaValidationState, loaValidationDispatch] = useReducer(loaReducer, null, initialLoaState); const { move, orders, isLoading, isError } = useOrdersDocumentQueries(moveCode); + const [orderTypeOptions, setOrderTypeOptions] = useState(ORDERS_TYPE_OPTIONS); const orderId = move?.ordersId; + const initialValueOfHasDependents = orders[orderId]?.has_dependents; const orderDocumentId = orders[orderId]?.uploaded_order_id; const amendedOrderDocumentId = orders[orderId]?.uploadedAmendedOrderID || amendedDocumentId; @@ -247,6 +254,19 @@ const ServicesCounselingOrders = ({ files, amendedDocumentId, updateAmendedDocum validateLoa, ]); + useEffect(() => { + const checkAlaskaFeatureFlag = async () => { + const isAlaskaEnabled = await isBooleanFlagEnabled(FEATURE_FLAG_KEYS.ENABLE_ALASKA); + if (!isAlaskaEnabled) { + const options = orderTypeOptions; + delete orderTypeOptions.EARLY_RETURN_OF_DEPENDENTS; + delete orderTypeOptions.STUDENT_TRAVEL; + setOrderTypeOptions(options); + } + }; + checkAlaskaFeatureFlag(); + }, [orderTypeOptions]); + if (isLoading) return ; if (isError) return ; @@ -264,6 +284,10 @@ const ServicesCounselingOrders = ({ files, amendedDocumentId, updateAmendedDocum reportByDate: formatSwaggerDate(values.reportByDate), ordersType: values.ordersType, grade: values.payGrade, + hasDependents: + values.ordersType === ORDERS_TYPE.STUDENT_TRAVEL || values.ordersType === ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS + ? formatYesNoAPIValue('yes') + : initialValueOfHasDependents, }; mutateOrders({ orderID: orderId, ifMatchETag: newOrderEtag, body: orderBody }); }; @@ -292,6 +316,8 @@ const ServicesCounselingOrders = ({ files, amendedDocumentId, updateAmendedDocum 'Unable to find a LOA based on the provided details. Please ensure a department indicator and TAC are present on this form.'; const loaInvalidWarningMsg = 'The LOA identified based on the provided details appears to be invalid.'; + const ordersTypeDropdownOptions = dropdownInputOptions(orderTypeOptions); + return (
diff --git a/src/pages/Office/ServicesCounselingOrders/ServicesCounselingOrders.test.jsx b/src/pages/Office/ServicesCounselingOrders/ServicesCounselingOrders.test.jsx index c6d994b9a84..b10032c6da9 100644 --- a/src/pages/Office/ServicesCounselingOrders/ServicesCounselingOrders.test.jsx +++ b/src/pages/Office/ServicesCounselingOrders/ServicesCounselingOrders.test.jsx @@ -7,6 +7,9 @@ import ServicesCounselingOrders from 'pages/Office/ServicesCounselingOrders/Serv import { MockProviders } from 'testUtils'; import { useOrdersDocumentQueries } from 'hooks/queries'; import { MOVE_DOCUMENT_TYPE } from 'shared/constants'; +import { counselingUpdateOrder, getOrder } from 'services/ghcApi'; +import { formatYesNoAPIValue } from 'utils/formatters'; +import { ORDERS_TYPE } from 'constants/orders'; const mockOriginDutyLocation = { address: { @@ -100,6 +103,13 @@ jest.mock('services/ghcApi', () => ({ // Default to no LOA return Promise.resolve(undefined); }, + counselingUpdateOrder: jest.fn(), + getOrder: jest.fn(), +})); + +jest.mock('utils/featureFlags', () => ({ + ...jest.requireActual('utils/featureFlags'), + isBooleanFlagEnabled: jest.fn().mockImplementation(() => Promise.resolve(true)), })); const useOrdersDocumentQueriesReturnValue = { @@ -218,6 +228,8 @@ describe('Orders page', () => { }); it('renders each option for orders type dropdown', async () => { + useOrdersDocumentQueries.mockReturnValue(useOrdersDocumentQueriesReturnValue); + render( @@ -238,6 +250,12 @@ describe('Orders page', () => { await userEvent.selectOptions(ordersTypeDropdown, 'SEPARATION'); expect(ordersTypeDropdown).toHaveValue('SEPARATION'); + + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS); + expect(ordersTypeDropdown).toHaveValue(ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS); + + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.STUDENT_TRAVEL); + expect(ordersTypeDropdown).toHaveValue(ORDERS_TYPE.STUDENT_TRAVEL); }); it('populates initial field values', async () => { @@ -409,6 +427,7 @@ describe('Orders page', () => { }); }); }); + describe('LOA concatenation', () => { it('concatenates the LOA string correctly', async () => { useOrdersDocumentQueries.mockReturnValue(useOrdersDocumentQueriesReturnValue); @@ -430,6 +449,7 @@ describe('Orders page', () => { expect(loaTextField).toHaveValue(expectedLongLineOfAccounting); }); }); + describe('LOA concatenation with regex removes extra spaces', () => { it('concatenates the LOA string correctly and without extra spaces', async () => { let extraSpacesLongLineOfAccounting = @@ -446,4 +466,332 @@ describe('Orders page', () => { expect(extraSpacesLongLineOfAccounting).toEqual(expectedLongLineOfAccounting); }); }); + + describe('Order type: STUDENT_TRAVEL', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('select STUDENT_TRAVEL', async () => { + // create a local copy of order return value and set initial values + const orderQueryReturnValues = JSON.parse(JSON.stringify(useOrdersDocumentQueriesReturnValue)); + orderQueryReturnValues.move = { id: 123, moveCode: 'GLOBAL123', ordersId: 1 }; + orderQueryReturnValues.orders[1].order_type = ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION; + orderQueryReturnValues.orders[1].has_dependents = formatYesNoAPIValue('no'); + + // set return values for mocked functions + useOrdersDocumentQueries.mockReturnValue(orderQueryReturnValues); + getOrder.mockResolvedValue(orderQueryReturnValues); + + // render component + render( + + + , + ); + + // Select STUDENT_TRAVEL from the dropdown + const ordersTypeDropdown = await screen.findByLabelText('Orders type'); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.STUDENT_TRAVEL); + + // Submit the form + const saveButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(saveButton); + + // Verify correct values were passed + await waitFor(() => { + expect(counselingUpdateOrder).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + hasDependents: formatYesNoAPIValue('yes'), + ordersType: ORDERS_TYPE.STUDENT_TRAVEL, + }), + }), + ); + }); + }); + + it('De-select STUDENT_TRAVEL', async () => { + // create a local copy of order return value and set initial values + const orderQueryReturnValues = JSON.parse(JSON.stringify(useOrdersDocumentQueriesReturnValue)); + orderQueryReturnValues.move = { id: 123, moveCode: 'GLOBAL123', ordersId: 1 }; + orderQueryReturnValues.orders[1].order_type = ORDERS_TYPE.STUDENT_TRAVEL; + orderQueryReturnValues.orders[1].has_dependents = formatYesNoAPIValue('yes'); + + // set return values for mocked functions + useOrdersDocumentQueries.mockReturnValue(orderQueryReturnValues); + getOrder.mockResolvedValue(orderQueryReturnValues); + + // render component + render( + + + , + ); + + // De-select STUDENT_TRAVEL from the dropdown + const ordersTypeDropdown = await screen.findByLabelText('Orders type'); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION); + + // Submit the form + const saveButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(saveButton); + + // Verify correct values were passed + await waitFor(() => { + expect(counselingUpdateOrder).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + hasDependents: formatYesNoAPIValue('yes'), + ordersType: ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION, + }), + }), + ); + }); + }); + + it('select STUDENT_TRAVEL, De-select STUDENT_TRAVEL', async () => { + // create a local copy of order return value and set initial values + const orderQueryReturnValues = JSON.parse(JSON.stringify(useOrdersDocumentQueriesReturnValue)); + orderQueryReturnValues.move = { id: 123, moveCode: 'GLOBAL123', ordersId: 1 }; + orderQueryReturnValues.orders[1].order_type = ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION; + orderQueryReturnValues.orders[1].has_dependents = formatYesNoAPIValue('no'); + + // set return values for mocked functions + useOrdersDocumentQueries.mockReturnValue(orderQueryReturnValues); + getOrder.mockResolvedValue(orderQueryReturnValues); + + // render component + render( + + + , + ); + + // Select EARLY_RETURN_OF_DEPENDENTS and then de-select from the dropdown + const ordersTypeDropdown = await screen.findByLabelText('Orders type'); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.STUDENT_TRAVEL); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.LOCAL_MOVE); + + // Submit the form + const saveButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(saveButton); + + // Verify correct values were passed + await waitFor(() => { + expect(counselingUpdateOrder).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + hasDependents: formatYesNoAPIValue('no'), + ordersType: ORDERS_TYPE.LOCAL_MOVE, + }), + }), + ); + }); + }); + + it('select STUDENT_TRAVEL, select EARLY_RETURN_OF_DEPENDENTS', async () => { + // create a local copy of order return value and set initial values + const orderQueryReturnValues = JSON.parse(JSON.stringify(useOrdersDocumentQueriesReturnValue)); + orderQueryReturnValues.move = { id: 123, moveCode: 'GLOBAL123', ordersId: 1 }; + orderQueryReturnValues.orders[1].order_type = ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION; + orderQueryReturnValues.orders[1].has_dependents = formatYesNoAPIValue('no'); + + // set return values for mocked functions + useOrdersDocumentQueries.mockReturnValue(orderQueryReturnValues); + getOrder.mockResolvedValue(orderQueryReturnValues); + + // render component + render( + + + , + ); + + // Select STUDENT_TRAVEL and then select EARLY_RETURN_OF_DEPENDENTS from the dropdown + const ordersTypeDropdown = await screen.findByLabelText('Orders type'); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.STUDENT_TRAVEL); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS); + + // Submit the form + const saveButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(saveButton); + + // Verify correct values were passed + await waitFor(() => { + expect(counselingUpdateOrder).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + hasDependents: formatYesNoAPIValue('yes'), + ordersType: ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS, + }), + }), + ); + }); + }); + }); + + describe('Order type: EARLY_RETURN_OF_DEPENDENTS', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('select EARLY_RETURN_OF_DEPENDENTS', async () => { + // create a local copy of order return value and set initial values + const orderQueryReturnValues = JSON.parse(JSON.stringify(useOrdersDocumentQueriesReturnValue)); + orderQueryReturnValues.move = { id: 123, moveCode: 'GLOBAL123', ordersId: 1 }; + orderQueryReturnValues.orders[1].order_type = ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION; + orderQueryReturnValues.orders[1].has_dependents = formatYesNoAPIValue('no'); + + // set return values for mocked functions + useOrdersDocumentQueries.mockReturnValue(orderQueryReturnValues); + getOrder.mockResolvedValue(orderQueryReturnValues); + + // render component + render( + + + , + ); + + // Select EARLY_RETURN_OF_DEPENDENTS from the dropdown + const ordersTypeDropdown = await screen.findByLabelText('Orders type'); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS); + + // Submit the form + const saveButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(saveButton); + + // Verify correct values were passed + await waitFor(() => { + expect(counselingUpdateOrder).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + hasDependents: formatYesNoAPIValue('yes'), + ordersType: ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS, + }), + }), + ); + }); + }); + + it('De-select EARLY_RETURN_OF_DEPENDENTS', async () => { + // create a local copy of order return value and set initial values + const orderQueryReturnValues = JSON.parse(JSON.stringify(useOrdersDocumentQueriesReturnValue)); + orderQueryReturnValues.move = { id: 123, moveCode: 'GLOBAL123', ordersId: 1 }; + orderQueryReturnValues.orders[1].order_type = ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS; + orderQueryReturnValues.orders[1].has_dependents = formatYesNoAPIValue('yes'); + + // set return values for mocked functions + useOrdersDocumentQueries.mockReturnValue(orderQueryReturnValues); + getOrder.mockResolvedValue(orderQueryReturnValues); + + // render component + render( + + + , + ); + + // De-select EARLY_RETURN_OF_DEPENDENTS from the dropdown + const ordersTypeDropdown = await screen.findByLabelText('Orders type'); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION); + + // Submit the form + const saveButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(saveButton); + + // Verify correct values were passed + await waitFor(() => { + expect(counselingUpdateOrder).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + hasDependents: formatYesNoAPIValue('yes'), + ordersType: ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION, + }), + }), + ); + }); + }); + + it('select EARLY_RETURN_OF_DEPENDENTS, De-select EARLY_RETURN_OF_DEPENDENTS', async () => { + // create a local copy of order return value and set initial values + const orderQueryReturnValues = JSON.parse(JSON.stringify(useOrdersDocumentQueriesReturnValue)); + orderQueryReturnValues.move = { id: 123, moveCode: 'GLOBAL123', ordersId: 1 }; + orderQueryReturnValues.orders[1].order_type = ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION; + orderQueryReturnValues.orders[1].has_dependents = formatYesNoAPIValue('no'); + + // set return values for mocked functions + useOrdersDocumentQueries.mockReturnValue(orderQueryReturnValues); + getOrder.mockResolvedValue(orderQueryReturnValues); + + // render component + render( + + + , + ); + + // Select EARLY_RETURN_OF_DEPENDENTS and then de-select from the dropdown + const ordersTypeDropdown = await screen.findByLabelText('Orders type'); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.LOCAL_MOVE); + + // Submit the form + const saveButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(saveButton); + + // Verify correct values were passed + await waitFor(() => { + expect(counselingUpdateOrder).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + hasDependents: formatYesNoAPIValue('no'), + ordersType: ORDERS_TYPE.LOCAL_MOVE, + }), + }), + ); + }); + }); + + it('select EARLY_RETURN_OF_DEPENDENTS, select STUDENT_TRAVEL', async () => { + // create a local copy of order return value and set initial values + const orderQueryReturnValues = JSON.parse(JSON.stringify(useOrdersDocumentQueriesReturnValue)); + orderQueryReturnValues.move = { id: 123, moveCode: 'GLOBAL123', ordersId: 1 }; + orderQueryReturnValues.orders[1].order_type = ORDERS_TYPE.PERMANENT_CHANGE_OF_STATION; + orderQueryReturnValues.orders[1].has_dependents = formatYesNoAPIValue('no'); + + // set return values for mocked functions + useOrdersDocumentQueries.mockReturnValue(orderQueryReturnValues); + getOrder.mockResolvedValue(orderQueryReturnValues); + + // render component + render( + + + , + ); + + // Select EARLY_RETURN_OF_DEPENDENTS and then select STUDENT_TRAVEL from the dropdown + const ordersTypeDropdown = await screen.findByLabelText('Orders type'); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.EARLY_RETURN_OF_DEPENDENTS); + await userEvent.selectOptions(ordersTypeDropdown, ORDERS_TYPE.STUDENT_TRAVEL); + + // Submit the form + const saveButton = screen.getByRole('button', { name: 'Save' }); + await userEvent.click(saveButton); + + // Verify correct values were passed + await waitFor(() => { + expect(counselingUpdateOrder).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + hasDependents: formatYesNoAPIValue('yes'), + ordersType: ORDERS_TYPE.STUDENT_TRAVEL, + }), + }), + ); + }); + }); + }); }); diff --git a/swagger-def/ghc.yaml b/swagger-def/ghc.yaml index 9231fd5a8d4..df06a4ca220 100644 --- a/swagger-def/ghc.yaml +++ b/swagger-def/ghc.yaml @@ -1421,6 +1421,7 @@ paths: $ref: '#/responses/ServerError' tags: - shipment + - shipment_address_updates description: This endpoint is used to approve a address update request. Office remarks are required. Approving the address update will update the Destination Final Address of the associated service item operationId: reviewShipmentAddressUpdate @@ -5753,6 +5754,10 @@ definitions: $ref: definitions/NullableString.yaml grade: $ref: '#/definitions/Grade' + hasDependents: + type: boolean + title: Are dependents included in your orders? + x-nullable: true required: - issueDate - reportByDate diff --git a/swagger/ghc.yaml b/swagger/ghc.yaml index 6aa00056580..a92ed3016a6 100644 --- a/swagger/ghc.yaml +++ b/swagger/ghc.yaml @@ -1474,6 +1474,7 @@ paths: $ref: '#/responses/ServerError' tags: - shipment + - shipment_address_updates description: >- This endpoint is used to approve a address update request. Office remarks are required. Approving the address update will update the @@ -6021,6 +6022,10 @@ definitions: $ref: '#/definitions/NullableString' grade: $ref: '#/definitions/Grade' + hasDependents: + type: boolean + title: Are dependents included in your orders? + x-nullable: true required: - issueDate - reportByDate