From efc424502f3cd91c6312cb9216b997b9f7466d80 Mon Sep 17 00:00:00 2001 From: Brian Donovan <1938+eventualbuddha@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:03:57 -0800 Subject: [PATCH] test(design): re-enable some more tests (#6017) --- apps/design/backend/src/app.test.ts | 30 +++-- apps/design/backend/test/helpers.ts | 14 +-- apps/design/frontend/.eslintignore | 7 -- .../src/ballot_order_info_screen.test.tsx | 2 +- .../frontend/src/ballot_screen.test.tsx | 13 +- .../frontend/src/ballots_screen.test.tsx | 36 +++--- .../src/election_info_screen.test.tsx | 74 +++++++----- .../frontend/src/elections_screen.test.tsx | 112 +++++++++++++----- .../frontend/src/export_screen.test.tsx | 62 ++++++---- .../frontend/src/features_context.test.tsx | 58 +++++++-- apps/design/frontend/src/features_context.tsx | 4 +- apps/design/frontend/src/image_input.test.tsx | 4 +- .../frontend/src/tabulation_screen.test.tsx | 2 +- apps/design/frontend/test/api_helpers.tsx | 12 +- apps/design/frontend/test/fixtures.ts | 66 ++++++----- apps/design/frontend/tsconfig.json | 8 +- apps/design/frontend/vitest.config.ts | 9 +- 17 files changed, 327 insertions(+), 186 deletions(-) diff --git a/apps/design/backend/src/app.test.ts b/apps/design/backend/src/app.test.ts index 5365482ea2..bb9d3f5532 100644 --- a/apps/design/backend/src/app.test.ts +++ b/apps/design/backend/src/app.test.ts @@ -85,6 +85,7 @@ import { ElectionRecord } from '.'; import { ElectionPackage, getTempBallotLanguageConfigsForCert } from './store'; import { renderBallotStyleReadinessReport } from './ballot_style_reports'; import { BALLOT_STYLE_READINESS_REPORT_FILE_NAME } from './app'; +import { join } from 'node:path'; vi.setConfig({ testTimeout: 60_000, @@ -1285,7 +1286,7 @@ test.skip('setBallotTemplate changes the ballot template used to render ballots' ).toHaveLength(props.length); }); -test.skip('v3-compatible election package', async () => { +test('v3-compatible election package', async () => { // This test runs unnecessarily long if we're generating exports for all // languages, so disabling multi-language support for this case: mockFeatureFlagger.disableFeatureFlag( @@ -1308,7 +1309,7 @@ test.skip('v3-compatible election package', async () => { ballotTemplateId: 'NhBallotV3', }); - const electionPackageFilePath = await exportElectionPackage({ + const electionPackageAndBallotsFileName = await exportElectionPackage({ user: vxUser, fileStorageClient, apiClient, @@ -1316,11 +1317,23 @@ test.skip('v3-compatible election package', async () => { workspace, electionSerializationFormat: 'vxf', }); - const zipFile = await openZip(readFileSync(electionPackageFilePath)); - const entries = getEntries(zipFile); + const electionPackageAndBallotsZip = await openZip( + fileStorageClient.getRawFile( + join(vxUser.orgId, electionPackageAndBallotsFileName) + )! + ); + const electionPackageAndBallotsZipEntries = getEntries( + electionPackageAndBallotsZip + ); + const electionPackageZipBuffer = await find( + electionPackageAndBallotsZipEntries, + (entry) => entry.name.startsWith('election-package') + ).async('nodebuffer'); + const electionPackageZip = await openZip(electionPackageZipBuffer); + const electionPackageZipEntries = getEntries(electionPackageZip); // eslint-disable-next-line @typescript-eslint/no-explicit-any const election: any = await readJsonEntry( - getFileByName(entries, ElectionPackageFileName.ELECTION) + getFileByName(electionPackageZipEntries, ElectionPackageFileName.ELECTION) ); // Date should be off-by-one to account for timezone bug in v3 expect(fixtureElection.date.toISOString()).toEqual('2021-06-06'); @@ -1329,7 +1342,10 @@ test.skip('v3-compatible election package', async () => { // System settings should have field names matching v3 format // eslint-disable-next-line @typescript-eslint/no-explicit-any const systemSettings: any = await readJsonEntry( - getFileByName(entries, ElectionPackageFileName.SYSTEM_SETTINGS) + getFileByName( + electionPackageZipEntries, + ElectionPackageFileName.SYSTEM_SETTINGS + ) ); expect(Object.keys(systemSettings)).toEqual([ 'auth', @@ -1340,5 +1356,5 @@ test.skip('v3-compatible election package', async () => { ]); // No other files included - expect(entries.length).toEqual(2); + expect(electionPackageAndBallotsZipEntries.length).toEqual(2); }); diff --git a/apps/design/backend/test/helpers.ts b/apps/design/backend/test/helpers.ts index eb780892f3..a37d2f81e8 100644 --- a/apps/design/backend/test/helpers.ts +++ b/apps/design/backend/test/helpers.ts @@ -81,6 +81,10 @@ class MockAuthClient extends AuthClient { class MockFileStorageClient implements FileStorageClient { private mockFiles: Record = {}; + getRawFile(filePath: string): Buffer | undefined { + return this.mockFiles[filePath]; + } + async readFile( filePath: string ): Promise> { @@ -183,7 +187,7 @@ export async function processNextBackgroundTaskIfAny({ } export const ELECTION_PACKAGE_FILE_NAME_REGEX = - /election-package-([0-9a-z]{7})-([0-9a-z]{7})\.zip$/; + /election-package-and-ballots-([0-9a-z]{7})-([0-9a-z]{7})\.zip$/; export async function exportElectionPackage({ user, @@ -213,13 +217,7 @@ export async function exportElectionPackage({ const electionPackage = await apiClient.getElectionPackage({ electionId, }); - const electionPackageFileName = assertDefined( + return assertDefined( assertDefined(electionPackage.url).match(ELECTION_PACKAGE_FILE_NAME_REGEX) )[0]; - const electionPackageFilePath = path.join( - workspace.assetDirectoryPath, - electionPackageFileName - ); - - return electionPackageFilePath; } diff --git a/apps/design/frontend/.eslintignore b/apps/design/frontend/.eslintignore index 161cdfdf81..34a5095975 100644 --- a/apps/design/frontend/.eslintignore +++ b/apps/design/frontend/.eslintignore @@ -11,11 +11,4 @@ src/app.test.tsx src/contests_screen.test.tsx -src/election_info_screen.test.tsx -src/elections_screen.test.tsx src/geography_screen.test.tsx -src/image_input.test.tsx -src/features_context.test.tsx -src/export_screen.test.tsx -test/api_helpers.tsx -test/fixtures.ts diff --git a/apps/design/frontend/src/ballot_order_info_screen.test.tsx b/apps/design/frontend/src/ballot_order_info_screen.test.tsx index 8845cac117..5c5f61d929 100644 --- a/apps/design/frontend/src/ballot_order_info_screen.test.tsx +++ b/apps/design/frontend/src/ballot_order_info_screen.test.tsx @@ -21,7 +21,7 @@ vi.useFakeTimers({ now: mockDateTime, }); -const electionRecord = generalElectionRecord; +const electionRecord = generalElectionRecord(nonVxUser.orgId); const electionId = electionRecord.election.id; let apiMock: MockApiClient; diff --git a/apps/design/frontend/src/ballot_screen.test.tsx b/apps/design/frontend/src/ballot_screen.test.tsx index 71ecdb86cf..86699bb341 100644 --- a/apps/design/frontend/src/ballot_screen.test.tsx +++ b/apps/design/frontend/src/ballot_screen.test.tsx @@ -18,9 +18,10 @@ import { withRoute } from '../test/routing_helpers'; import { routes } from './routes'; import { BallotScreen } from './ballot_screen'; -const electionId = generalElectionRecord.election.id; -const ballotStyle = generalElectionRecord.ballotStyles[0]; -const precinct = generalElectionRecord.precincts[0]; +const electionRecord = generalElectionRecord(nonVxUser.orgId); +const electionId = electionRecord.election.id; +const ballotStyle = electionRecord.ballotStyles[0]; +const precinct = electionRecord.precincts[0]; function MockDocument({ children, @@ -88,7 +89,7 @@ test('shows a PDF ballot preview', async () => { apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection .expectCallWith({ user: nonVxUser, electionId }) - .resolves(generalElectionRecord); + .resolves(electionRecord); apiMock.getBallotPreviewPdf .expectCallWith({ electionId, @@ -155,7 +156,7 @@ test('changes ballot type', async () => { apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection .expectCallWith({ user: nonVxUser, electionId }) - .resolves(generalElectionRecord); + .resolves(electionRecord); apiMock.getBallotPreviewPdf .expectCallWith({ electionId, @@ -209,7 +210,7 @@ test('changes tabulation mode', async () => { apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection .expectCallWith({ user: nonVxUser, electionId }) - .resolves(generalElectionRecord); + .resolves(electionRecord); apiMock.getBallotPreviewPdf .expectCallWith({ electionId, diff --git a/apps/design/frontend/src/ballots_screen.test.tsx b/apps/design/frontend/src/ballots_screen.test.tsx index 9be718c59a..6319d5dfab 100644 --- a/apps/design/frontend/src/ballots_screen.test.tsx +++ b/apps/design/frontend/src/ballots_screen.test.tsx @@ -40,11 +40,12 @@ function renderScreen(electionId: ElectionId) { describe('Ballot styles tab', () => { test('General election with splits', async () => { - const electionId = generalElectionRecord.election.id; + const electionRecord = generalElectionRecord(nonVxUser.orgId); + const electionId = electionRecord.election.id; apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection .expectCallWith({ user: nonVxUser, electionId }) - .resolves(generalElectionRecord); + .resolves(electionRecord); apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); renderScreen(electionId); await screen.findByRole('heading', { name: 'Proof Ballots' }); @@ -77,11 +78,12 @@ describe('Ballot styles tab', () => { }); test('Primary election with splits', async () => { - const electionId = primaryElectionRecord.election.id; + const electionRecord = primaryElectionRecord(nonVxUser.orgId); + const electionId = electionRecord.election.id; apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection .expectCallWith({ user: nonVxUser, electionId }) - .resolves(primaryElectionRecord); + .resolves(electionRecord); apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); renderScreen(electionId); await screen.findByRole('heading', { name: 'Proof Ballots' }); @@ -121,13 +123,14 @@ describe('Ballot styles tab', () => { }); test('Precincts/splits with no ballot styles show a message', async () => { + const record = generalElectionRecord(nonVxUser.orgId); const electionRecord: ElectionRecord = { - ...generalElectionRecord, - ballotStyles: generalElectionRecord.ballotStyles.filter( + ...record, + ballotStyles: record.ballotStyles.filter( (ballotStyle) => ballotStyle.id === '2_en' ), }; - const electionId = generalElectionRecord.election.id; + const electionId = electionRecord.election.id; apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection .expectCallWith({ user: nonVxUser, electionId }) @@ -156,11 +159,12 @@ describe('Ballot styles tab', () => { }); test('Finalizing ballots', async () => { - const electionId = generalElectionRecord.election.id; + const electionRecord = generalElectionRecord(nonVxUser.orgId); + const electionId = electionRecord.election.id; apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection .expectCallWith({ user: nonVxUser, electionId }) - .resolves(generalElectionRecord); + .resolves(electionRecord); apiMock.getBallotsFinalizedAt .expectOptionalRepeatedCallsWith({ electionId }) .resolves(null); @@ -198,13 +202,14 @@ describe('Ballot styles tab', () => { }); test('Ballot layout tab - VX User', async () => { - const { election } = generalElectionRecord; + const electionRecord = generalElectionRecord(vxUser.orgId); + const { election } = electionRecord; const electionId = election.id; apiMock.getUser.expectCallWith().resolves(vxUser); apiMock.getElection .expectCallWith({ user: vxUser, electionId }) - .resolves(generalElectionRecord); + .resolves(electionRecord); apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); renderScreen(electionId); await screen.findByRole('heading', { name: 'Proof Ballots' }); @@ -255,7 +260,7 @@ test('Ballot layout tab - VX User', async () => { }) .resolves(); apiMock.getElection.expectCallWith({ user: vxUser, electionId }).resolves({ - ...generalElectionRecord, + ...electionRecord, election: updatedElection, }); userEvent.click(screen.getByRole('button', { name: /Save/ })); @@ -265,13 +270,14 @@ test('Ballot layout tab - VX User', async () => { }); test('Ballot layout tab - NH User', async () => { - const { election } = generalElectionRecord; + const electionRecord = generalElectionRecord(nonVxUser.orgId); + const { election } = electionRecord; const electionId = election.id; apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection .expectCallWith({ user: nonVxUser, electionId }) - .resolves(generalElectionRecord); + .resolves(electionRecord); apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); renderScreen(electionId); await screen.findByRole('heading', { name: 'Proof Ballots' }); @@ -318,7 +324,7 @@ test('Ballot layout tab - NH User', async () => { }) .resolves(); apiMock.getElection.expectCallWith({ user: nonVxUser, electionId }).resolves({ - ...generalElectionRecord, + ...electionRecord, election: updatedElection, }); userEvent.click(screen.getByRole('button', { name: /Save/ })); diff --git a/apps/design/frontend/src/election_info_screen.test.tsx b/apps/design/frontend/src/election_info_screen.test.tsx index b9892445df..7f68148562 100644 --- a/apps/design/frontend/src/election_info_screen.test.tsx +++ b/apps/design/frontend/src/election_info_screen.test.tsx @@ -1,3 +1,4 @@ +import { afterEach, beforeEach, expect, test } from 'vitest'; import userEvent from '@testing-library/user-event'; import { ElectionId } from '@votingworks/types'; import { Buffer } from 'node:buffer'; @@ -7,11 +8,13 @@ import { ElectionInfo } from '@votingworks/design-backend'; import { MockApiClient, createMockApiClient, + nonVxUser, provideApi, + vxUser, } from '../test/api_helpers'; import { - blankElectionInfo, blankElectionRecord, + electionInfoFromElection, generalElectionInfo, generalElectionRecord, } from '../test/fixtures'; @@ -47,15 +50,17 @@ function renderScreen(electionId: ElectionId) { } test('newly created election starts in edit mode', async () => { - const electionId = blankElectionRecord.election.id; + const electionRecord = blankElectionRecord(nonVxUser.orgId); + const electionId = electionRecord.election.id; + apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection - .expectCallWith({ electionId }) - .resolves(blankElectionRecord); + .expectCallWith({ user: nonVxUser, electionId }) + .resolves(electionRecord); apiMock.getElectionInfo - .expectCallWith({ electionId: blankElectionRecord.election.id }) - .resolves(blankElectionInfo); + .expectCallWith({ electionId: electionRecord.election.id }) + .resolves(electionInfoFromElection(electionRecord.election)); apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); - renderScreen(blankElectionRecord.election.id); + renderScreen(electionRecord.election.id); await screen.findByRole('heading', { name: 'Election Info' }); const titleInput = screen.getByLabelText('Title'); @@ -90,18 +95,23 @@ test('newly created election starts in edit mode', async () => { userEvent.click(screen.getByRole('button', { name: 'Cancel' })); screen.getByRole('button', { name: 'Edit' }); - screen.getByRole('button', { name: 'Delete Election' }); + // Delete Election is not available to non-Vx users + expect( + screen.queryByRole('button', { name: 'Delete Election' }) + ).not.toBeInTheDocument(); }); test('edit and save election', async () => { - const { election } = generalElectionRecord; + const electionRecord = generalElectionRecord(nonVxUser.orgId); + const { election } = electionRecord; const electionId = election.id; + apiMock.getUser.expectRepeatedCallsWith().resolves(nonVxUser); apiMock.getElection - .expectCallWith({ electionId }) - .resolves(generalElectionRecord); + .expectCallWith({ user: nonVxUser, electionId }) + .resolves(electionRecord); apiMock.getElectionInfo .expectCallWith({ electionId }) - .resolves(generalElectionInfo); + .resolves(electionInfoFromElection(electionRecord.election)); apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); renderScreen(electionId); await screen.findByRole('heading', { name: 'Election Info' }); @@ -181,17 +191,17 @@ test('edit and save election', async () => { seal: 'updated seal', }; apiMock.updateElectionInfo.expectCallWith(updatedElectionInfo).resolves(); - apiMock.getElection.expectCallWith({ electionId }).resolves({ - ...generalElectionRecord, + apiMock.getElection.expectCallWith({ user: nonVxUser, electionId }).resolves({ + ...electionRecord, election: { - ...generalElectionRecord.election, + ...electionRecord.election, type: updatedElectionInfo.type, title: updatedElectionInfo.title, date: updatedElectionInfo.date, state: updatedElectionInfo.state, seal: updatedElectionInfo.seal, county: { - id: generalElectionRecord.election.county.id, + id: electionRecord.election.county.id, name: updatedElectionInfo.jurisdiction, }, }, @@ -205,13 +215,15 @@ test('edit and save election', async () => { }); test('cancel update', async () => { - const electionId = generalElectionRecord.election.id; + const electionRecord = generalElectionRecord(nonVxUser.orgId); + const electionId = electionRecord.election.id; + apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection - .expectCallWith({ electionId }) - .resolves(generalElectionRecord); + .expectCallWith({ user: nonVxUser, electionId }) + .resolves(electionRecord); apiMock.getElectionInfo .expectCallWith({ electionId }) - .resolves(generalElectionInfo); + .resolves(electionInfoFromElection(electionRecord.election)); apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); renderScreen(electionId); await screen.findByRole('heading', { name: 'Election Info' }); @@ -224,17 +236,19 @@ test('cancel update', async () => { userEvent.click(screen.getByRole('button', { name: 'Cancel' })); - expect(titleInput).toHaveValue(generalElectionRecord.election.title); + expect(titleInput).toHaveValue(electionRecord.election.title); }); test('delete election', async () => { - const electionId = generalElectionRecord.election.id; + const electionRecord = generalElectionRecord(nonVxUser.orgId); + const electionId = electionRecord.election.id; + apiMock.getUser.expectCallWith().resolves(vxUser); apiMock.getElection - .expectCallWith({ electionId }) - .resolves(generalElectionRecord); + .expectCallWith({ user: vxUser, electionId }) + .resolves(electionRecord); apiMock.getElectionInfo .expectCallWith({ electionId }) - .resolves(generalElectionInfo); + .resolves(electionInfoFromElection(electionRecord.election)); apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); const history = renderScreen(electionId); await screen.findByRole('heading', { name: 'Election Info' }); @@ -255,13 +269,15 @@ test('delete election', async () => { }); test('edit election disabled when ballots are finalized', async () => { - const electionId = generalElectionRecord.election.id; + const electionRecord = generalElectionRecord(nonVxUser.orgId); + const electionId = electionRecord.election.id; + apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection - .expectCallWith({ electionId }) - .resolves(generalElectionRecord); + .expectCallWith({ user: nonVxUser, electionId }) + .resolves(electionRecord); apiMock.getElectionInfo .expectCallWith({ electionId }) - .resolves(generalElectionInfo); + .resolves(electionInfoFromElection(electionRecord.election)); apiMock.getBallotsFinalizedAt .expectCallWith({ electionId }) .resolves(new Date()); diff --git a/apps/design/frontend/src/elections_screen.test.tsx b/apps/design/frontend/src/elections_screen.test.tsx index e8089de89b..fe277c6942 100644 --- a/apps/design/frontend/src/elections_screen.test.tsx +++ b/apps/design/frontend/src/elections_screen.test.tsx @@ -1,10 +1,13 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { ok } from '@votingworks/basics'; import userEvent from '@testing-library/user-event'; import { createMemoryHistory } from 'history'; +import { ElectionId, ElectionIdSchema, unsafeParse } from '@votingworks/types'; import { MockApiClient, createMockApiClient, provideApi, + vxUser, } from '../test/api_helpers'; import { blankElectionRecord, @@ -16,6 +19,14 @@ import { withRoute } from '../test/routing_helpers'; import { ElectionsScreen } from './elections_screen'; import { routes } from './routes'; +// Pin all IDs to a known value for deterministic tests. +const ID = 'ID'; +const ELECTION_ID = unsafeParse(ElectionIdSchema, ID); +vi.mock(import('./utils.js'), async (importActual) => ({ + ...(await importActual()), + generateId: () => ID, +})); + let apiMock: MockApiClient; beforeEach(() => { @@ -26,7 +37,7 @@ afterEach(() => { apiMock.assertComplete(); }); -function renderScreen() { +function renderScreen(electionId?: ElectionId) { const history = createMemoryHistory(); const result = render( provideApi( @@ -35,7 +46,8 @@ function renderScreen() { paramPath: routes.root.path, path: routes.root.path, history, - }) + }), + electionId ) ); return { @@ -45,36 +57,61 @@ function renderScreen() { } test('with no elections, creating a new election', async () => { - apiMock.listElections.expectCallWith().resolves([]); + apiMock.getUser.expectCallWith().resolves(vxUser); + apiMock.getAllOrgs.expectCallWith().resolves([ + { + id: vxUser.orgId, + name: 'VotingWorks', + displayName: 'VotingWorks', + }, + ]); + apiMock.listElections.expectCallWith({ user: vxUser }).resolves([]); const { history } = renderScreen(); await screen.findByRole('heading', { name: 'Elections' }); - screen.getByText("You haven't created any elections yet."); + const electionRecord = blankElectionRecord(vxUser.orgId); apiMock.createElection - .expectCallWith({ id: blankElectionRecord.election.id }) - .resolves(ok(blankElectionRecord.election.id)); - apiMock.listElections.expectCallWith().resolves([blankElectionRecord]); + .expectCallWith({ + user: vxUser, + orgId: vxUser.orgId, + id: ELECTION_ID, + }) + .resolves(ok(ELECTION_ID)); + apiMock.listElections + .expectCallWith({ user: vxUser }) + .resolves([electionRecord]); const createElectionButton = screen.getByRole('button', { name: 'Create Election', }); userEvent.click(createElectionButton); + userEvent.type(screen.getByRole('combobox'), 'VotingWorks[Enter]'); + userEvent.click(screen.getByRole('button', { name: 'Confirm' })); await waitFor(() => { - expect(history.location.pathname).toEqual( - `/elections/${blankElectionRecord.election.id}` - ); + expect(history.location.pathname).toEqual(`/elections/${ELECTION_ID}`); }); }); test('with no elections, loading an election', async () => { - apiMock.listElections.expectCallWith().resolves([]); + const electionRecord = primaryElectionRecord(vxUser.orgId); + apiMock.getUser.expectCallWith().resolves(vxUser); + apiMock.getAllOrgs.expectCallWith().resolves([ + { + id: vxUser.orgId, + name: 'VotingWorks', + displayName: 'VotingWorks', + }, + ]); + apiMock.listElections.expectCallWith({ user: vxUser }).resolves([]); const { history } = renderScreen(); await screen.findByRole('heading', { name: 'Elections' }); - const electionData = JSON.stringify(primaryElectionRecord.election); + const electionData = JSON.stringify(electionRecord.election); apiMock.loadElection - .expectCallWith({ electionData }) - .resolves(ok(primaryElectionRecord.election.id)); - apiMock.listElections.expectCallWith().resolves([primaryElectionRecord]); + .expectCallWith({ user: vxUser, orgId: vxUser.orgId, electionData }) + .resolves(ok(electionRecord.election.id)); + apiMock.listElections + .expectCallWith({ user: vxUser }) + .resolves([electionRecord]); const loadElectionInput = screen.getByLabelText('Load Election'); const file = new File([electionData], 'election.json', { type: 'application/json', @@ -84,52 +121,67 @@ test('with no elections, loading an election', async () => { userEvent.upload(loadElectionInput, file); await waitFor(() => { expect(history.location.pathname).toEqual( - `/elections/${primaryElectionRecord.election.id}` + `/elections/${electionRecord.election.id}` ); }); }); test('with elections', async () => { + const [general, primary] = [ + generalElectionRecord(vxUser.orgId), + primaryElectionRecord(vxUser.orgId), + ]; + apiMock.getUser.expectCallWith().resolves(vxUser); + apiMock.getAllOrgs.expectCallWith().resolves([ + { + id: vxUser.orgId, + name: 'VotingWorks', + displayName: 'VotingWorks', + }, + ]); apiMock.listElections - .expectCallWith() - .resolves([generalElectionRecord, primaryElectionRecord]); + .expectCallWith({ user: vxUser }) + .resolves([general, primary]); const { history } = renderScreen(); await screen.findByRole('heading', { name: 'Elections' }); const table = screen.getByRole('table'); const headers = within(table).getAllByRole('columnheader'); expect(headers.map((header) => header.textContent)).toEqual([ + 'Status', + 'Org', + 'Jurisdiction', 'Title', 'Date', - 'Jurisdiction', - 'State', ]); const rows = within(table).getAllByRole('row').slice(1); expect( rows.map((row) => within(row) .getAllByRole('cell') - .map((cell) => cell.textContent) + .map((cell) => cell.textContent?.trim()) ) ).toEqual([ [ - generalElectionRecord.election.title, - 'November 3, 2020', - generalElectionRecord.election.county.name, - generalElectionRecord.election.state, + 'In progress', + 'VotingWorks', + general.election.county.name, + general.election.title, + 'Nov 3, 2020', ], [ - primaryElectionRecord.election.title, - 'September 8, 2021', - primaryElectionRecord.election.county.name, - primaryElectionRecord.election.state, + 'In progress', + 'VotingWorks', + primary.election.county.name, + primary.election.title, + 'Sep 8, 2021', ], ]); userEvent.click(rows[0]); await waitFor(() => { expect(history.location.pathname).toEqual( - `/elections/${generalElectionRecord.election.id}` + `/elections/${general.election.id}` ); }); }); diff --git a/apps/design/frontend/src/export_screen.test.tsx b/apps/design/frontend/src/export_screen.test.tsx index dc07d05586..a6610df50b 100644 --- a/apps/design/frontend/src/export_screen.test.tsx +++ b/apps/design/frontend/src/export_screen.test.tsx @@ -1,3 +1,4 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { Buffer } from 'node:buffer'; import fileDownload from 'js-file-download'; import userEvent from '@testing-library/user-event'; @@ -6,6 +7,7 @@ import { provideApi, createMockApiClient, MockApiClient, + nonVxUser, } from '../test/api_helpers'; import { render, screen, waitFor } from '../test/react_testing_library'; import { withRoute } from '../test/routing_helpers'; @@ -14,23 +16,25 @@ import { routes } from './routes'; import { downloadFile } from './utils'; import { generalElectionRecord } from '../test/fixtures'; -const electionId = generalElectionRecord.election.id; +const electionRecord = generalElectionRecord(nonVxUser.orgId); +const electionId = electionRecord.election.id; -jest.mock('js-file-download'); -const fileDownloadMock = jest.mocked(fileDownload); +vi.mock('js-file-download'); +const fileDownloadMock = vi.mocked(fileDownload); -jest.mock('./utils', (): typeof import('./utils') => ({ - ...jest.requireActual('./utils'), - downloadFile: jest.fn(), +vi.mock(import('./utils'), async (importActual) => ({ + ...(await importActual()), + downloadFile: vi.fn(), })); let apiMock: MockApiClient; beforeEach(() => { apiMock = createMockApiClient(); + apiMock.getUser.expectCallWith().resolves(nonVxUser); apiMock.getElection - .expectCallWith({ electionId }) - .resolves(generalElectionRecord); + .expectCallWith({ user: nonVxUser, electionId }) + .resolves(electionRecord); apiMock.getElectionPackage.expectCallWith({ electionId }).resolves({}); apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); }); @@ -52,10 +56,11 @@ function renderScreen() { ); } -test('export all ballots', async () => { +test.skip('export all ballots', async () => { renderScreen(); await screen.findAllByRole('heading', { name: 'Export' }); + // @ts-expect-error - exportAllBallots was removed apiMock.exportAllBallots .expectCallWith({ electionId, electionSerializationFormat: 'vxf' }) .resolves({ @@ -100,7 +105,11 @@ test('export election package and ballots', async () => { const taskCreatedAt = new Date(); apiMock.exportElectionPackage - .expectCallWith({ electionId, electionSerializationFormat: 'vxf' }) + .expectCallWith({ + user: nonVxUser, + electionId, + electionSerializationFormat: 'vxf', + }) .resolves(); apiMock.getElectionPackage.expectRepeatedCallsWith({ electionId }).resolves({ task: { @@ -151,7 +160,11 @@ test('export election package error handling', async () => { const taskCreatedAt = new Date(); apiMock.exportElectionPackage - .expectCallWith({ electionId, electionSerializationFormat: 'vxf' }) + .expectCallWith({ + user: nonVxUser, + electionId, + electionSerializationFormat: 'vxf', + }) .resolves(); apiMock.getElectionPackage.expectRepeatedCallsWith({ electionId }).resolves({ task: { @@ -191,7 +204,7 @@ test('export election package error handling', async () => { expect(mockOf(downloadFile)).not.toHaveBeenCalled(); }); -test('using CDF', async () => { +test.skip('using CDF', async () => { renderScreen(); await screen.findAllByRole('heading', { name: 'Export' }); @@ -206,6 +219,7 @@ test('using CDF', async () => { checked: true, }); + // @ts-expect-error - exportAllBallots was removed apiMock.exportAllBallots .expectCallWith({ electionId, electionSerializationFormat: 'cdf' }) .resolves({ @@ -235,7 +249,11 @@ test('using CDF', async () => { }); apiMock.exportElectionPackage - .expectCallWith({ electionId, electionSerializationFormat: 'cdf' }) + .expectCallWith({ + user: nonVxUser, + electionId, + electionSerializationFormat: 'cdf', + }) .resolves(); apiMock.getElectionPackage.expectRepeatedCallsWith({ electionId }).resolves({ task: { @@ -275,8 +293,8 @@ test('set ballot template', async () => { ballotTemplateId: 'NhBallot', }) .resolves(); - apiMock.getElection.expectCallWith({ electionId }).resolves({ - ...generalElectionRecord, + apiMock.getElection.expectCallWith({ user: nonVxUser, electionId }).resolves({ + ...electionRecord, ballotTemplateId: 'NhBallot', }); userEvent.click(select); @@ -303,9 +321,7 @@ test('view ballot proofing status and unfinalize ballots', async () => { const select = screen.getByLabelText('Ballot Template'); expect(select).toBeDisabled(); - apiMock.setBallotsFinalizedAt - .expectCallWith({ electionId, finalizedAt: null }) - .resolves(); + apiMock.unfinalizeBallots.expectCallWith({ electionId }).resolves(); apiMock.getBallotsFinalizedAt.expectCallWith({ electionId }).resolves(null); userEvent.click(screen.getButton('Unfinalize Ballots')); await screen.findByText('Ballots not finalized'); @@ -316,8 +332,8 @@ test('view ballot proofing status and unfinalize ballots', async () => { test('view ballot order status and unsubmit order', async () => { const submittedAt = '1/30/2025, 12:00 PM'; apiMock.getElection.reset(); - apiMock.getElection.expectCallWith({ electionId }).resolves({ - ...generalElectionRecord, + apiMock.getElection.expectCallWith({ user: nonVxUser, electionId }).resolves({ + ...electionRecord, ballotOrderInfo: { absenteeBallotCount: '100', orderSubmittedAt: new Date(submittedAt).toISOString(), @@ -327,7 +343,7 @@ test('view ballot order status and unsubmit order', async () => { renderScreen(); await screen.findAllByRole('heading', { name: 'Export' }); - screen.getByText(`Order submitted at: ${submittedAt}`); + await screen.findByText(`Order submitted at: ${submittedAt}`); apiMock.updateBallotOrderInfo .expectCallWith({ @@ -338,8 +354,8 @@ test('view ballot order status and unsubmit order', async () => { }, }) .resolves(); - apiMock.getElection.expectCallWith({ electionId }).resolves({ - ...generalElectionRecord, + apiMock.getElection.expectCallWith({ user: nonVxUser, electionId }).resolves({ + ...electionRecord, ballotOrderInfo: { absenteeBallotCount: '100', }, diff --git a/apps/design/frontend/src/features_context.test.tsx b/apps/design/frontend/src/features_context.test.tsx index 276a10113e..8d518ab925 100644 --- a/apps/design/frontend/src/features_context.test.tsx +++ b/apps/design/frontend/src/features_context.test.tsx @@ -1,15 +1,19 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { ElectionRecord } from '@votingworks/design-backend'; import { createMockApiClient, MockApiClient, + nonVxUser, provideApi, + vxUser, } from '../test/api_helpers'; import { generalElectionRecord } from '../test/fixtures'; import { renderHook, waitFor } from '../test/react_testing_library'; import { - DEFAULT_ENABLED_FEATURES, - NH_ENABLED_FEATURES, - useFeaturesContext, + electionFeatureConfigs, + useElectionFeatures, + userFeatureConfigs, + useUserFeatures, } from './features_context'; let apiMock: MockApiClient; @@ -22,23 +26,55 @@ afterEach(() => { apiMock.assertComplete(); }); -test('returns default feature set if no election specified', () => { - const { result } = renderHook(useFeaturesContext); - expect(result.current).toEqual(DEFAULT_ENABLED_FEATURES); +test('returns VX feature set for VX election', async () => { + const vxElection = generalElectionRecord(vxUser.orgId); + apiMock.getUser.expectRepeatedCallsWith().resolves(vxUser); + apiMock.getElection + .expectRepeatedCallsWith({ + user: vxUser, + electionId: vxElection.election.id, + }) + .resolves(vxElection); + const userHook = renderHook(() => useUserFeatures(), { + wrapper: ({ children }) => + provideApi(apiMock, children, vxElection.election.id), + }); + await vi.waitFor(() => { + expect(userHook.result.current).toEqual(userFeatureConfigs.vx); + }); + + const electionHook = renderHook(() => useElectionFeatures(), { + wrapper: ({ children }) => + provideApi(apiMock, children, vxElection.election.id), + }); + await vi.waitFor(() => { + expect(electionHook.result.current).toEqual(electionFeatureConfigs.vx); + }); }); test('returns NH feature set for NH election', async () => { + const electionRecord = generalElectionRecord(nonVxUser.orgId); const nhElection: ElectionRecord = { - ...generalElectionRecord, - election: { ...generalElectionRecord.election, state: 'NH' }, + ...electionRecord, + election: { ...electionRecord.election, state: 'NH' }, }; const electionId = nhElection.election.id; - apiMock.getElection.expectCallWith({ electionId }).resolves(nhElection); + apiMock.getUser.expectRepeatedCallsWith().resolves(nonVxUser); + apiMock.getElection + .expectRepeatedCallsWith({ user: nonVxUser, electionId }) + .resolves(nhElection); + + const userHook = renderHook(() => useUserFeatures(), { + wrapper: ({ children }) => provideApi(apiMock, children, electionId), + }); + await waitFor(() => { + expect(userHook.result.current).toEqual(userFeatureConfigs.nh); + }); - const { result } = renderHook(() => useFeaturesContext(), { + const electionHook = renderHook(() => useElectionFeatures(), { wrapper: ({ children }) => provideApi(apiMock, children, electionId), }); await waitFor(() => { - expect(result.current).toEqual(NH_ENABLED_FEATURES); + expect(electionHook.result.current).toEqual(electionFeatureConfigs.nh); }); }); diff --git a/apps/design/frontend/src/features_context.tsx b/apps/design/frontend/src/features_context.tsx index 721669d55c..a6ea8ab4d9 100644 --- a/apps/design/frontend/src/features_context.tsx +++ b/apps/design/frontend/src/features_context.tsx @@ -84,7 +84,7 @@ interface FeaturesConfig { election?: ElectionFeaturesConfig; } -const userFeatureConfigs = { +export const userFeatureConfigs = { vx: { ACCESS_ALL_ORGS: true, TABULATION_SCREEN: true, @@ -109,7 +109,7 @@ const userFeatureConfigs = { }, } satisfies Record; -const electionFeatureConfigs = { +export const electionFeatureConfigs = { // VX sandbox elections should have not have any state-specific features // enabled vx: { diff --git a/apps/design/frontend/src/image_input.test.tsx b/apps/design/frontend/src/image_input.test.tsx index 87defe7994..d5ff4b6b47 100644 --- a/apps/design/frontend/src/image_input.test.tsx +++ b/apps/design/frontend/src/image_input.test.tsx @@ -28,7 +28,7 @@ describe('ImageInput', () => { beforeEach(() => { globalThis.Image = function Image() { return mockImage; - } as any; + } as unknown as typeof globalThis.Image; }); test('accepts and sanitizes SVGs', async () => { @@ -174,7 +174,7 @@ describe('ImageInput', () => { }); }); -test('regression test #5967: does not crash when canceling an upload', async () => { +test('regression test #5967: does not crash when canceling an upload', () => { const onChange = vi.fn(); render( , children: React.ReactNode, - electionId: ElectionId + electionId?: ElectionId ): JSX.Element { return ( - - {children} - + {electionId ? ( + + {children} + + ) : ( + children + )} diff --git a/apps/design/frontend/test/fixtures.ts b/apps/design/frontend/test/fixtures.ts index 89e9bc4bf4..f064e5d4aa 100644 --- a/apps/design/frontend/test/fixtures.ts +++ b/apps/design/frontend/test/fixtures.ts @@ -13,11 +13,15 @@ import { DEFAULT_SYSTEM_SETTINGS, Election, ElectionId, + Id, LanguageCode, } from '@votingworks/types'; import { generateId } from '../src/utils'; -export function makeElectionRecord(baseElection: Election): ElectionRecord { +export function makeElectionRecord( + baseElection: Election, + orgId: Id +): ElectionRecord { const ballotLanguageConfigs: BallotLanguageConfigs = [ { languages: [LanguageCode.ENGLISH] }, ]; @@ -49,32 +53,40 @@ export function makeElectionRecord(baseElection: Election): ElectionRecord { ballotLanguageConfigs, ballotTemplateId: 'VxDefaultBallot', ballotsFinalizedAt: null, - orgId: 'TODO', + orgId, }; } -export const blankElectionRecord = makeElectionRecord( - createBlankElection(generateId() as ElectionId) -); -export const blankElectionInfo: ElectionInfo = { - electionId: blankElectionRecord.election.id, - type: blankElectionRecord.election.type, - date: blankElectionRecord.election.date, - title: blankElectionRecord.election.title, - jurisdiction: blankElectionRecord.election.county.name, - state: blankElectionRecord.election.state, - seal: blankElectionRecord.election.seal, -}; -export const generalElectionRecord = makeElectionRecord(readElectionGeneral()); -export const primaryElectionRecord = makeElectionRecord( - electionPrimaryPrecinctSplitsFixtures.readElection() -); -export const generalElectionInfo: ElectionInfo = { - electionId: generalElectionRecord.election.id, - type: generalElectionRecord.election.type, - date: generalElectionRecord.election.date, - title: generalElectionRecord.election.title, - jurisdiction: generalElectionRecord.election.county.name, - state: generalElectionRecord.election.state, - seal: generalElectionRecord.election.seal, -}; +export function electionInfoFromElection(election: Election): ElectionInfo { + return { + electionId: election.id, + title: election.title, + date: election.date, + type: election.type, + state: election.state, + jurisdiction: election.county.name, + seal: election.seal, + }; +} + +export function blankElectionRecord(orgId: Id): ElectionRecord { + return makeElectionRecord( + createBlankElection(generateId() as ElectionId), + orgId + ); +} +export function blankElectionInfo(orgId: Id): ElectionInfo { + return electionInfoFromElection(blankElectionRecord(orgId).election); +} +export function generalElectionRecord(orgId: Id): ElectionRecord { + return makeElectionRecord(readElectionGeneral(), orgId); +} +export function primaryElectionRecord(orgId: Id): ElectionRecord { + return makeElectionRecord( + electionPrimaryPrecinctSplitsFixtures.readElection(), + orgId + ); +} +export function generalElectionInfo(orgId: Id): ElectionInfo { + return electionInfoFromElection(generalElectionRecord(orgId).election); +} diff --git a/apps/design/frontend/tsconfig.json b/apps/design/frontend/tsconfig.json index e7b69e9342..4fb069ff7e 100644 --- a/apps/design/frontend/tsconfig.json +++ b/apps/design/frontend/tsconfig.json @@ -18,13 +18,7 @@ "exclude": [ "src/app.test.tsx", "src/contests_screen.test.tsx", - "src/election_info_screen.test.tsx", - "src/elections_screen.test.tsx", - "src/geography_screen.test.tsx", - "src/features_context.test.tsx", - "src/export_screen.test.tsx", - "test/api_helpers.tsx", - "test/fixtures.ts" + "src/geography_screen.test.tsx" ], "references": [ { "path": "../backend/tsconfig.build.json" }, diff --git a/apps/design/frontend/vitest.config.ts b/apps/design/frontend/vitest.config.ts index f4a3917a69..7311447209 100644 --- a/apps/design/frontend/vitest.config.ts +++ b/apps/design/frontend/vitest.config.ts @@ -8,17 +8,14 @@ export default defineConfig({ exclude: [ 'src/app.test.ts', 'src/contests_screen.test.tsx', - 'src/election_info_screen.test.tsx', - 'src/elections_screen.test.tsx', 'src/geography_screen.test.tsx', - 'src/features_context.test.tsx', - 'src/export_screen.test.tsx', ], + clearMocks: true, coverage: { thresholds: { - lines: 42, - branches: 33, + lines: 58, + branches: 43, }, exclude: ['src/**/*.d.ts', 'src/index.tsx'], },