diff --git a/.env.example b/.env.example
index 8dc48032..3963f06b 100644
--- a/.env.example
+++ b/.env.example
@@ -7,5 +7,6 @@ NEXT_PUBLIC_TURNSTILE_SITE_KEY=cloudflare_turnstile_site_key_goes_here
TURNSTILE_SECRET_KEY=cloudflare_turnstile_secret_key_goes_here
NEXT_PUBLIC_SUPABASE_URL=supabase_api_url_goes_here
NEXT_PUBLIC_SUPABASE_ANON_KEY=supabase_anon_key_goes_here
-SUPABASE_SERVICE_ROLE_KEY=supabase_service_role_key_goes_here
+SUPABASE_SERVICE_ROLE_KEY=supabase_service_role_key_goes_here
+GOOGLE_MAPS_API_KEY=google_maps_api_key_here
VOTER_REGISTRATION_REPO_ENCRYPTION_KEY="result of npm run create-cryptokey"
\ No newline at end of file
diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml
index 3453634d..4e3d0d5a 100644
--- a/.github/workflows/run-unit-tests.yml
+++ b/.github/workflows/run-unit-tests.yml
@@ -13,6 +13,7 @@ jobs:
NEXT_PUBLIC_SUPABASE_URL: http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
+ GOOGLE_MAPS_API_KEY: ''
VOTER_REGISTRATION_REPO_ENCRYPTION_KEY: 'DvViBWSQfwFGSetPOVbIWZrMXYJh4wTVSE/+1QI/VTI='
steps:
- uses: actions/checkout@v4
diff --git a/jest.config.mjs b/jest.config.mjs
index ab5ff541..4e9df6d6 100644
--- a/jest.config.mjs
+++ b/jest.config.mjs
@@ -23,7 +23,13 @@ const config = {
'/src/constants/',
'fonts',
'/src/model/',
- 'user-context/user-context-provider.tsx',
+ '/src/app/register/progress-bar',
+ // ignore async server components as the test environment doesn't support rendering them at this time
+ '/src/contexts/user-context/user-context-provider.tsx',
+ '/src/app/register/addresses/page.tsx',
+ '/src/app/register/eligibility/page.tsx',
+ '/src/app/register/names/page.tsx',
+ '/src/app/register/other-details/page.tsx',
],
//require 100% code coverage for the tests to pass
coverageThreshold: {
diff --git a/package-lock.json b/package-lock.json
index 91448b81..1c99306f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,7 @@
"@paralleldrive/cuid2": "^2.2.2",
"@supabase/ssr": "^0.4.0",
"@supabase/supabase-js": "^2.44.0",
- "fully-formed": "^1.0.1",
+ "fully-formed": "^1.2.1",
"luxon": "^3.4.4",
"next": "^14.2.4",
"react": "^18.3.1",
@@ -11831,9 +11831,9 @@
}
},
"node_modules/fully-formed": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/fully-formed/-/fully-formed-1.0.1.tgz",
- "integrity": "sha512-LANG6ubGDdttvv6ed8ogEMbq/RnIYI/tSNLo4yK+cHJFpYiT0ZIxUaD0qH2rZPJi4nf4uQtL7l/3loX2AhoWKw==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/fully-formed/-/fully-formed-1.2.1.tgz",
+ "integrity": "sha512-QZr6XWK5VKN7xftt7O8V/LviQjBcJ5CGDAozCSoNO+eygzD4OAGise6K2iALZS3Y3gFq6byEUuB11rKeITwZ9Q==",
"license": "MIT",
"dependencies": {
"just-clone": "^6.2.0",
diff --git a/package.json b/package.json
index aee82d02..454ff580 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"@paralleldrive/cuid2": "^2.2.2",
"@supabase/ssr": "^0.4.0",
"@supabase/supabase-js": "^2.44.0",
- "fully-formed": "^1.0.1",
+ "fully-formed": "^1.2.1",
"luxon": "^3.4.4",
"next": "^14.2.4",
"react": "^18.3.1",
diff --git a/public/static/images/components/checkbox/checkmark.svg b/public/static/images/components/checkbox/checkmark.svg
new file mode 100644
index 00000000..8aa1324d
--- /dev/null
+++ b/public/static/images/components/checkbox/checkmark.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/static/images/components/shared/warning-icon-dark.svg b/public/static/images/components/shared/warning-icon-dark.svg
new file mode 100644
index 00000000..7a0ea0f4
--- /dev/null
+++ b/public/static/images/components/shared/warning-icon-dark.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/static/images/components/shared/warning-icon-light.svg b/public/static/images/components/shared/warning-icon-light.svg
new file mode 100644
index 00000000..652ac7d2
--- /dev/null
+++ b/public/static/images/components/shared/warning-icon-light.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/__tests__/unit/app/api/validate-addresses/route.test.tsx b/src/__tests__/unit/app/api/validate-addresses/route.test.tsx
new file mode 100644
index 00000000..9da3b2d2
--- /dev/null
+++ b/src/__tests__/unit/app/api/validate-addresses/route.test.tsx
@@ -0,0 +1,271 @@
+import { POST } from '@/app/api/validate-addresses/route';
+import { serverContainer } from '@/services/server/container';
+import { SERVER_SERVICE_KEYS } from '@/services/server/keys';
+import { saveActualImplementation } from '@/utils/test/save-actual-implementation';
+import { NextRequest } from 'next/server';
+import { AddressErrorTypes } from '@/model/types/addresses/address-error-types';
+import { ServerError } from '@/errors/server-error';
+import type { ValidateAddresses } from '@/services/server/validate-addresses/validate-addresses';
+import type { AddressErrors } from '@/model/types/addresses/address-errors';
+
+describe('POST', () => {
+ const getActualService = saveActualImplementation(serverContainer, 'get');
+
+ it(`returns a response with a result.errors property containing an empty array
+ if all addresses were valid.`, async () => {
+ const containerSpy = jest
+ .spyOn(serverContainer, 'get')
+ .mockImplementation(key => {
+ if (key.name === SERVER_SERVICE_KEYS.validateAddresses.name) {
+ const validateAddresses: ValidateAddresses = () => {
+ return Promise.resolve([] as AddressErrors[]);
+ };
+
+ return validateAddresses;
+ }
+
+ return getActualService(key);
+ });
+
+ const request = new NextRequest(
+ 'https://challenge.8by8.us/api/validate-addresses',
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ homeAddress: {
+ streetLine1: '1600 Amphitheatre Pkwy',
+ city: 'Mountain View',
+ state: 'CA',
+ zip: '94043',
+ },
+ }),
+ },
+ );
+
+ const response = await POST(request);
+ const body = await response.json();
+ expect(body).toEqual({
+ result: {
+ errors: [],
+ },
+ });
+
+ containerSpy.mockRestore();
+ });
+
+ it(`returns a response with a result.errors property containing errors if any
+ were detected.`, async () => {
+ const addresses = {
+ homeAddress: {
+ streetLine1: '2930 Pearl Street',
+ streetLine2: 'Suite 100',
+ city: 'Boulder',
+ state: 'CO',
+ zip: '80301',
+ },
+ mailingAddress: {
+ streetLine1: '1600 Amphitheatre Pkwy',
+ city: 'Montan View',
+ state: 'CA',
+ zip: '94043',
+ },
+ previousAddress: {
+ streetLine1: '500 W 2nd St',
+ city: 'Austin',
+ state: 'TX',
+ zip: '78701',
+ },
+ };
+
+ const errors: AddressErrors[] = [
+ {
+ type: AddressErrorTypes.UnconfirmedComponents,
+ form: 'homeAddress',
+ unconfirmedAddressComponents: {
+ streetLine1: {
+ value: addresses.homeAddress.streetLine1,
+ hasIssue: false,
+ },
+ streetLine2: {
+ value: addresses.homeAddress.streetLine2,
+ hasIssue: true,
+ },
+ city: {
+ value: addresses.homeAddress.city,
+ hasIssue: false,
+ },
+ state: {
+ value: addresses.homeAddress.state,
+ hasIssue: false,
+ },
+ zip: {
+ value: addresses.homeAddress.zip,
+ hasIssue: false,
+ },
+ },
+ },
+ {
+ type: AddressErrorTypes.ReviewRecommendedAddress,
+ form: 'mailingAddress',
+ enteredAddress: {
+ streetLine1: {
+ value: addresses.mailingAddress.streetLine1,
+ hasIssue: false,
+ },
+ city: {
+ value: addresses.mailingAddress.city,
+ hasIssue: true,
+ },
+ state: {
+ value: addresses.mailingAddress.state,
+ hasIssue: false,
+ },
+ zip: {
+ value: addresses.mailingAddress.zip,
+ hasIssue: false,
+ },
+ },
+ recommendedAddress: {
+ streetLine1: {
+ value: addresses.mailingAddress.streetLine1,
+ hasIssue: false,
+ },
+ city: {
+ value: addresses.mailingAddress.city,
+ hasIssue: true,
+ },
+ state: {
+ value: addresses.mailingAddress.state,
+ hasIssue: false,
+ },
+ zip: {
+ value: addresses.mailingAddress.zip,
+ hasIssue: false,
+ },
+ },
+ },
+ {
+ type: AddressErrorTypes.MissingSubpremise,
+ form: 'previousAddress',
+ },
+ ];
+
+ const containerSpy = jest
+ .spyOn(serverContainer, 'get')
+ .mockImplementation(key => {
+ if (key.name === SERVER_SERVICE_KEYS.validateAddresses.name) {
+ const validateAddresses: ValidateAddresses = () => {
+ return Promise.resolve(errors);
+ };
+
+ return validateAddresses;
+ }
+
+ return getActualService(key);
+ });
+
+ const request = new NextRequest(
+ 'https://challenge.8by8.us/api/validate-addresses',
+ {
+ method: 'POST',
+ body: JSON.stringify(addresses),
+ },
+ );
+
+ const response = await POST(request);
+ const body = await response.json();
+ expect(body).toEqual({
+ result: {
+ errors,
+ },
+ });
+
+ containerSpy.mockRestore();
+ });
+
+ it(`returns a response with a status of 400 if the data sent in the body
+ could not be parsed.`, async () => {
+ const request = new NextRequest(
+ 'https://challenge.8by8.us/api/validate-addresses',
+ {
+ method: 'POST',
+ body: JSON.stringify({}),
+ },
+ );
+
+ const response = await POST(request);
+ expect(response.status).toBe(400);
+ });
+
+ it('returns a response with a status matching that of a caught ServerError.', async () => {
+ const containerSpy = jest
+ .spyOn(serverContainer, 'get')
+ .mockImplementation(key => {
+ if (key.name === SERVER_SERVICE_KEYS.validateAddresses.name) {
+ const validateAddresses: ValidateAddresses = () => {
+ throw new ServerError('Too many requests.', 429);
+ };
+
+ return validateAddresses;
+ }
+
+ return getActualService(key);
+ });
+
+ const request = new NextRequest(
+ 'https://challenge.8by8.us/api/validate-addresses',
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ homeAddress: {
+ streetLine1: '1600 Amphitheatre Pkwy',
+ city: 'Mountain View',
+ state: 'CA',
+ zip: '94043',
+ },
+ }),
+ },
+ );
+
+ const response = await POST(request);
+ expect(response.status).toBe(429);
+
+ containerSpy.mockRestore();
+ });
+
+ it(`returns a response with a status of 500 when an unknown error is encountered.`, async () => {
+ const containerSpy = jest
+ .spyOn(serverContainer, 'get')
+ .mockImplementation(key => {
+ if (key.name === SERVER_SERVICE_KEYS.validateAddresses.name) {
+ const validateAddresses: ValidateAddresses = () => {
+ throw new Error();
+ };
+
+ return validateAddresses;
+ }
+
+ return getActualService(key);
+ });
+
+ const request = new NextRequest(
+ 'https://challenge.8by8.us/api/validate-addresses',
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ homeAddress: {
+ streetLine1: '1600 Amphitheatre Pkwy',
+ city: 'Mountain View',
+ state: 'CA',
+ zip: '94043',
+ },
+ }),
+ },
+ );
+
+ const response = await POST(request);
+ expect(response.status).toBe(500);
+
+ containerSpy.mockRestore();
+ });
+});
diff --git a/src/__tests__/unit/app/progress/__snapshots__/page.snapshot.tsx.snap b/src/__tests__/unit/app/progress/__snapshots__/page.snapshot.tsx.snap
index 43c210be..2d194cbf 100644
--- a/src/__tests__/unit/app/progress/__snapshots__/page.snapshot.tsx.snap
+++ b/src/__tests__/unit/app/progress/__snapshots__/page.snapshot.tsx.snap
@@ -94,11 +94,12 @@ exports[`Progress renders progress page unchanged 1`] = `
Not registered to vote yet?
Register now
- and earn a badge!
+
+ and earn a badge!
@@ -355,11 +356,12 @@ exports[`Progress renders progress page unchanged 1`] = `
Not registered to vote yet?
Register now
- and earn a badge!
+
+ and earn a badge!
@@ -375,6 +377,7 @@ exports[`Progress renders progress page unchanged 1`] = `