diff --git a/AGENTS.md b/AGENTS.md index 6f61439..339a4d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ | Area | Choice | | -------------------- | ------------------------------------------------------------------------- | +| Runtime | Node.js 22 or newer (`engines` in package.json) | | Frontend | React, TypeScript, Vite, React Router | | API | GraphQL (custom backend, Node + TypeScript, Apollo Server) | | GraphQL client | Apollo Client | @@ -52,9 +53,13 @@ npm run lint # Format npm run format:check -# Run unit/component tests (Vitest, watch mode) +# Run all tests once: frontend (Vitest) and backend (node --test), as CI does. +# No running backend or database is needed; component tests mock Apollo. npm run test +# Re-run frontend tests on every file change while developing +npm run test:watch + # Run end-to-end tests # TODO: e2e tool not chosen yet ``` diff --git a/backend/package.json b/backend/package.json index 4501772..dcb0cc0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,7 @@ "build": "tsc -p .", "start": "node --env-file-if-exists=.env dist/index.js", "lint": "eslint .", - "test": "node --test \"src/**/*.test.ts\"" + "test": "tsx --test \"src/**/*.test.ts\"" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/frontend/package.json b/frontend/package.json index 2200f48..ca3111f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "vitest" + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@apollo/client": "^4.3.2", diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx index 13de282..9491a77 100644 --- a/frontend/src/pages/SubmitSightingPage.tsx +++ b/frontend/src/pages/SubmitSightingPage.tsx @@ -95,6 +95,9 @@ function SubmitSightingPage() { const [submittedId, setSubmittedId] = useState() const [saveFailed, setSaveFailed] = useState(false) const [createSighting, { loading }] = useMutation(CREATE_SIGHTING_MUTATION) + // Fixing a field sets its error to undefined but keeps the key, so check + // the values rather than counting keys. + const hasErrors = Object.values(errors).some(Boolean) function updateValue(field: keyof FormValues, value: string) { setValues((previous) => ({ ...previous, [field]: value })) @@ -426,7 +429,7 @@ function SubmitSightingPage() {

)} - {Object.keys(errors).length > 0 && ( + {hasErrors && (

Please correct the highlighted fields.

diff --git a/frontend/src/tests/SearchPage.test.tsx b/frontend/src/tests/SearchPage.test.tsx index 9e4715e..ea23cf1 100644 --- a/frontend/src/tests/SearchPage.test.tsx +++ b/frontend/src/tests/SearchPage.test.tsx @@ -2,7 +2,7 @@ import { ApolloClient, ApolloLink } from '@apollo/client' import { ApolloProvider } from '@apollo/client/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter } from 'react-router' -import { of, throwError } from 'rxjs' +import { delay, of, throwError } from 'rxjs' import { describe, expect, it, vi } from 'vitest' import { mockSightings } from './mock-sightings' import { createCache } from '../graphql/client' @@ -22,13 +22,17 @@ vi.mock('../components/SightingMap', () => ({ * Like the real backend, the cursor is the offset of the next row. Pages * hold 5 sightings regardless of `first`, so the 12 mock sightings span * three pages. `failRequest` makes that request (1 = the first) fail like - * an unreachable backend. + * an unreachable backend. `latencyMs` delays every answer like a real + * network, so states that only exist while waiting can be seen. */ const FAKE_PAGE_SIZE = 5 function renderSearchPage( initialEntry = '/', - { failRequest }: { failRequest?: number } = {}, + { + failRequest, + latencyMs = 0, + }: { failRequest?: number; latencyMs?: number } = {}, ) { const requests: SightingsQueryVariables[] = [] const link = new ApolloLink((operation) => { @@ -61,7 +65,7 @@ function renderSearchPage( totalCount: matches.length, }, }, - }) + }).pipe(delay(latencyMs)) }) const client = new ApolloClient({ link, cache: createCache() }) @@ -208,4 +212,56 @@ describe('SearchPage', () => { ).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) + + describe('result states with a slow backend', () => { + const LATENCY_MS = 100 + + it('shows a loading message until the first results arrive', async () => { + renderSearchPage('/', { latencyMs: LATENCY_MS }) + + expect(screen.getByText('Loading sightings…')).toBeInTheDocument() + // The header is a live region, so screen readers hear it too. + expect(screen.getByText('Loading…')).toBeInTheDocument() + expect(screen.queryByText(/No sightings match/)).not.toBeInTheDocument() + + expect( + await screen.findByText('Showing 5 of 12 results'), + ).toBeInTheDocument() + expect(screen.queryByText('Loading sightings…')).not.toBeInTheDocument() + }) + + it('explains when a search has no matches', async () => { + renderSearchPage('/?q=atlantis', { latencyMs: LATENCY_MS }) + + expect( + await screen.findByText(/No sightings match your search/), + ).toBeInTheDocument() + expect(screen.getByText('0 results')).toBeInTheDocument() + expect(screen.queryByRole('list')).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Load more' }), + ).not.toBeInTheDocument() + }) + + it('keeps the previous results visible but busy while a new search loads', async () => { + renderSearchPage('/', { latencyMs: LATENCY_MS }) + await screen.findByText('Showing 5 of 12 results') + + fireEvent.change(screen.getByRole('searchbox'), { + target: { value: 'oslo' }, + }) + + // After the debounce the new request is in flight: the old list stays + // instead of flashing to a loading message. + await waitFor(() => + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true'), + ) + expect(screen.getAllByRole('link')).toHaveLength(5) + expect(screen.queryByText('Loading sightings…')).not.toBeInTheDocument() + + expect(await screen.findByText('1 result')).toBeInTheDocument() + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false') + expect(screen.getAllByRole('link')).toHaveLength(1) + }) + }) }) diff --git a/frontend/src/tests/SubmitSightingPage.test.tsx b/frontend/src/tests/SubmitSightingPage.test.tsx index 4c84245..b7080be 100644 --- a/frontend/src/tests/SubmitSightingPage.test.tsx +++ b/frontend/src/tests/SubmitSightingPage.test.tsx @@ -1,20 +1,29 @@ +import { ApolloProvider } from '@apollo/client/react' import { MockedProvider, type MockedProviderProps, } from '@apollo/client/testing/react' -import { InMemoryCache } from '@apollo/client' +import { ApolloClient, ApolloLink, InMemoryCache } from '@apollo/client' import { GraphQLError } from 'graphql' -import { fireEvent, render, screen } from '@testing-library/react' -import { MemoryRouter } from 'react-router' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router' +import { delay, of } from 'rxjs' import { describe, expect, it, vi } from 'vitest' +import Banner from '../components/Banner' +import { createCache } from '../graphql/client' import { CREATE_SIGHTING_MUTATION } from '../graphql/mutations' import { SIGHTINGS_QUERY } from '../graphql/queries' +import SearchPage from '../pages/SearchPage' import SubmitSightingPage from '../pages/SubmitSightingPage' +import { mockSightings } from './mock-sightings' -// Leaflet needs a real browser layout, and the map is not under test here. +// Leaflet needs a real browser layout, and the maps are not under test here. vi.mock('../components/LocationPicker', () => ({ default: () =>
, })) +vi.mock('../components/SightingMap', () => ({ + default: () =>
, +})) const input = { datetime: '2024-03-10T21:15', @@ -57,6 +66,11 @@ function renderPage( } function fillForm() { + fillFields() + fireEvent.click(screen.getByRole('button', { name: 'Submit sighting' })) +} + +function fillFields() { const fields: [string, string][] = [ ['Date and time *', input.datetime], ['City *', input.city], @@ -70,7 +84,62 @@ function fillForm() { for (const [label, value] of fields) { fireEvent.change(screen.getByLabelText(label), { target: { value } }) } - fireEvent.click(screen.getByRole('button', { name: 'Submit sighting' })) +} + +/** + * A fake backend that behaves like the database: createSighting stores + * the sighting, and later `sightings` queries return it first. It records + * the name of every request, so tests can count them. `latencyMs` delays + * every answer like a real network. + */ +function renderWithFakeDatabase( + initialEntry = '/submit', + { latencyMs = 0 } = {}, +) { + const operations: string[] = [] + const database = [...mockSightings] + + const link = new ApolloLink((operation) => { + operations.push(operation.operationName ?? 'unknown') + + if (operation.operationName === 'CreateSighting') { + database.unshift(created) + return of({ data: { createSighting: created } }).pipe(delay(latencyMs)) + } + + return of({ + data: { + sightings: { + __typename: 'SightingConnection', + edges: database.map((node) => ({ + __typename: 'SightingEdge', + cursor: node.id, + node: { __typename: 'Sighting', ...node }, + })), + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + endCursor: null, + }, + totalCount: database.length, + }, + }, + }).pipe(delay(latencyMs)) + }) + const client = new ApolloClient({ link, cache: createCache() }) + + render( + + + + + } /> + } /> + + + , + ) + return operations } describe('SubmitSightingPage', () => { @@ -168,4 +237,76 @@ describe('SubmitSightingPage', () => { screen.getByRole('button', { name: 'Submit sighting' }), ).toBeEnabled() }) + + describe('wanted behavior', () => { + it('sends nothing when required fields are missing, and links each error to its field', async () => { + const operations = renderWithFakeDatabase() + + fireEvent.click(screen.getByRole('button', { name: 'Submit sighting' })) + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Please correct the highlighted fields.', + ) + expect(screen.getByLabelText('City *')).toHaveAccessibleDescription( + 'Enter the city.', + ) + expect(screen.getByLabelText('Date and time *')).toHaveAttribute( + 'aria-invalid', + 'true', + ) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(operations).toEqual([]) + }) + + it('removes the error summary once every highlighted field is fixed', () => { + renderWithFakeDatabase() + fireEvent.click(screen.getByRole('button', { name: 'Submit sighting' })) + expect( + screen.getByText('Please correct the highlighted fields.'), + ).toBeInTheDocument() + + fillFields() + + expect(screen.getByLabelText('City *')).toHaveAttribute( + 'aria-invalid', + 'false', + ) + expect( + screen.queryByText('Please correct the highlighted fields.'), + ).not.toBeInTheDocument() + }) + + it('sends one request when the submit button is clicked twice', async () => { + const operations = renderWithFakeDatabase('/submit', { latencyMs: 50 }) + + fillFields() + const button = screen.getByRole('button', { name: 'Submit sighting' }) + fireEvent.click(button) + fireEvent.click(button) + + expect( + await screen.findByRole('heading', { name: 'Sighting submitted' }), + ).toBeInTheDocument() + expect(operations).toEqual(['CreateSighting']) + }) + + it('shows the submitted sighting in the search list afterwards', async () => { + renderWithFakeDatabase('/') + // The list has been loaded and is now in Apollo's cache. + await screen.findByText('12 results') + + fireEvent.click(screen.getByRole('link', { name: 'Report a sighting' })) + fillForm() + await screen.findByRole('heading', { name: 'Sighting submitted' }) + + fireEvent.click(screen.getByRole('link', { name: 'Back to sightings' })) + + await waitFor(() => + expect(screen.getByText('13 results')).toBeInTheDocument(), + ) + expect( + screen.getByRole('link', { name: 'trondheim, NO' }), + ).toBeInTheDocument() + }) + }) }) diff --git a/package-lock.json b/package-lock.json index c38c2d4..3e5635a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ ], "devDependencies": { "prettier": "^3.9.9" + }, + "engines": { + "node": ">=22" } }, "backend": { diff --git a/package.json b/package.json index d884746..16487ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "t25-project-2", "private": true, + "engines": { + "node": ">=22" + }, "workspaces": [ "frontend", "backend" @@ -11,6 +14,7 @@ "build": "npm run build --workspaces --if-present", "lint": "npm run lint --workspaces --if-present", "test": "npm run test --workspaces --if-present", + "test:watch": "npm run test:watch -w frontend", "format": "prettier --write .", "format:check": "prettier --check ." },