From 3a35dbbff3915341f407cf7ae41d362a56e43d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20M=C3=BCller-Grud?= Date: Mon, 12 Oct 2026 13:14:13 +0200 Subject: [PATCH 1/6] Test: Cover search page loading and empty states Give the fake backend an optional latency so states that only exist while waiting can be tested, and test the first load, a serach with no matches and a new serach while old results are shown. Refs #43 --- frontend/src/tests/SearchPage.test.tsx | 64 ++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/frontend/src/tests/SearchPage.test.tsx b/frontend/src/tests/SearchPage.test.tsx index 9d32c26..bd7a790 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 '../data/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) + }) + }) }) From 6b7a7a34acb4e7d4e96c6cd81a5dfe64e0f90419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20M=C3=BCller-Grud?= Date: Wed, 14 Oct 2026 09:45:58 +0200 Subject: [PATCH 2/6] Fix: Hide submit error summary once fields are fixed The summary counted keys in the errors object, but fixing a field set its error to undefined without removing the key. Now it checks for actual messages instead. Fixes #63 --- frontend/src/pages/SubmitSightingPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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.

From da683b6d40845b44a6e3b6e5ff3aa4a93752387f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20M=C3=BCller-Grud?= Date: Wed, 14 Oct 2026 09:50:19 +0200 Subject: [PATCH 3/6] Test: Cover wanted submit form behavior Check that an invalid form sends no request and links each error to its feild, that the error summary goes away once the fileds are fixed, that a double click sends on requests, and that a sbmitted sighting shows up in the search list. Uses a fake database that stores the sighting and counts requests. Refs #43, #64 --- .../src/tests/SubmitSightingPage.test.tsx | 151 +++++++++++++++++- 1 file changed, 146 insertions(+), 5 deletions(-) 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() + }) + }) }) From faef6e9a990dee5c344c61a9486d8c6849405e1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20M=C3=BCller-Grud?= Date: Wed, 14 Oct 2026 09:56:00 +0200 Subject: [PATCH 4/6] Chore: Make npm test run all tests once npm test started Vitest in watch mode and only reached the backend tests after quitting it. Run Vitest once by default, like CI, and moved watch mode to npm run test:watch. Documented in AGENTS.md. Refs #43 --- AGENTS.md | 6 +++++- frontend/package.json | 3 ++- package.json | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c3e01a7..3cb86e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,9 +52,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/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/package.json b/package.json index d884746..b63ed29 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,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 ." }, From b1972dba5761dec12276000cedc260475b138dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20M=C3=BCller-Grud?= Date: Thu, 15 Oct 2026 10:20:38 +0200 Subject: [PATCH 5/6] Fix: Run backend tests through tsx on Node 22+ node --test only runs .ts files by itself from Node 22.18, so npm test failed with ERR_UNKNOWN_FILE_EXTENSION on older Node 22 version. Now the tests are ran through tsx instead, and Node 22 is set as the minimum version in package.json. Refs #43 --- AGENTS.md | 1 + backend/package.json | 2 +- package-lock.json | 3 +++ package.json | 3 +++ 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 3cb86e5..26ea71e 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 | 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/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 b63ed29..16487ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "t25-project-2", "private": true, + "engines": { + "node": ">=22" + }, "workspaces": [ "frontend", "backend" From 9e5026bd233f561a4a3b7138989d69478b799f0d Mon Sep 17 00:00:00 2001 From: Asgeir Jacobsen Date: Thu, 15 Oct 2026 10:38:30 +0200 Subject: [PATCH 6/6] Chore: Retrigger checks