diff --git a/AGENTS.md b/AGENTS.md index 53c4160..26ea71e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,13 +5,14 @@ - **Name:** UFOund it - **What it is:** UFOund it, is a webapp that displays UFO sightings. - **Dataset:** [NUFORC UFO Sightings (Kaggle)](https://www.kaggle.com/datasets/NUFORC/ufo-sightings), CSV, approx 80,000 reports, approx 15mb. -- **Current state:** The backend is an Apollo Server with `sightings`, `sighting` and `createSighting`, backed by MySQL on the VM. The search page fetches sightings through Apollo Client, with search, sort and "load more" pagination done in the database. The detail page still looks sightings up in the hand-written mock data in `frontend/src/data/`. A temporary footer shows whether the backend is reachable. +- **Current state:** The backend is an Apollo Server with `sightings`, `sighting` and `createSighting`, backed by MySQL on the VM. The search and detail pages fetch sightings through Apollo Client, with search, sort and "load more" pagination done in the database. A temporary footer shows whether the backend is reachable. - **Course context:** NTNU IT2810 project. Must be reproducible, accessible and sustainable. ## Tech stack | 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 ``` @@ -69,7 +74,7 @@ frontend/ hooks/ Custom hooks (debounce, sighting search, single sighting) utils/ Pure helpers (formatting) types/ Shared TypeScript types - data/ Mock sightings, still used by the detail page and as test fixtures + tests/ Vitest tests and the mock sightings they use as fixtures graphql/ Apollo Client setup and pagination cache rules (client.ts), queries.ts and mutations.ts backend/ Node + TypeScript GraphQL server (Apollo Server 5) src/ 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/backend/src/db/sightings.ts b/backend/src/db/sightings.ts index ca259a7..79e71e8 100644 --- a/backend/src/db/sightings.ts +++ b/backend/src/db/sightings.ts @@ -132,6 +132,10 @@ export async function findSightings(args: SightingsArgs) { } export async function findSightingById(id: string) { + // MySQL reads '1abc' as 1 when comparing it with a number column, which + // would show the wrong sighting instead of "not found". + if (!/^\d+$/.test(id)) return null + const [rows] = await pool.query( `${SELECT} ${FROM} WHERE s.UFO_SIGHTING_ID = ?`, [id], 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/data/sighting-store.ts b/frontend/src/data/sighting-store.ts deleted file mode 100644 index 7b8c35b..0000000 --- a/frontend/src/data/sighting-store.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { mockSightings } from './mock-sightings' -import type { UfoSighting } from '../types/ufo-sighting' - -let sightings = [...mockSightings] -const listeners = new Set<() => void>() - -export function getSightings(): UfoSighting[] { - return sightings -} - -export function subscribeToSightings(listener: () => void): () => void { - listeners.add(listener) - return () => listeners.delete(listener) -} - -/** - * Adds a sighting the backend has already stored. The list and detail - * pages still read from this store, so without it a new sighting would - * not show up until those pages fetch from GraphQL. - */ -export function addSighting(sighting: UfoSighting) { - sightings = [sighting, ...sightings] - listeners.forEach((listener) => listener()) -} diff --git a/frontend/src/graphql/queries.ts b/frontend/src/graphql/queries.ts index 74b4121..3d39a4a 100644 --- a/frontend/src/graphql/queries.ts +++ b/frontend/src/graphql/queries.ts @@ -71,3 +71,30 @@ export const SIGHTINGS_QUERY: TypedDocumentNode< } } ` + +interface SightingQueryData { + /** Null if no sighting has the id. */ + sighting: UfoSighting | null +} + +/** A single sighting by id, for the detail page. */ +export const SIGHTING_QUERY: TypedDocumentNode< + SightingQueryData, + { id: string } +> = gql` + query Sighting($id: ID!) { + sighting(id: $id) { + id + datetime + city + state + country + shape + durationSeconds + comments + datePosted + latitude + longitude + } + } +` diff --git a/frontend/src/hooks/use-sighting.ts b/frontend/src/hooks/use-sighting.ts index 16d5fd2..6f83ab8 100644 --- a/frontend/src/hooks/use-sighting.ts +++ b/frontend/src/hooks/use-sighting.ts @@ -1,11 +1,20 @@ -import type { UfoSighting } from '../types/ufo-sighting' -import { useSightings } from './use-sightings' +import { useQuery } from '@apollo/client/react' +import { SIGHTING_QUERY } from '../graphql/queries' /** - * Looks up a single sighting by id. Wrapped in a hook so the detail page - * won't need to change when this becomes a GraphQL query. + * Fetches one sighting. `sighting` is null once the load has finished and + * the backend has no sighting with that id, which is not an `error`. */ -export function useSighting(id: string | undefined): UfoSighting | undefined { - const sightings = useSightings() - return sightings.find((sighting) => sighting.id === id) +export function useSighting(id: string) { + const { data, loading, error, refetch } = useQuery(SIGHTING_QUERY, { + variables: { id }, + }) + + // A failed refetch is shown through `error`, so the rejected promise + // needs no handling of its own. + const retry = () => { + refetch().catch(() => {}) + } + + return { sighting: data?.sighting ?? null, loading, error, retry } } diff --git a/frontend/src/hooks/use-sightings.ts b/frontend/src/hooks/use-sightings.ts deleted file mode 100644 index 96a532c..0000000 --- a/frontend/src/hooks/use-sightings.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useSyncExternalStore } from 'react' -import { getSightings, subscribeToSightings } from '../data/sighting-store' - -export function useSightings() { - return useSyncExternalStore(subscribeToSightings, getSightings, getSightings) -} diff --git a/frontend/src/pages/SightingDetailPage.tsx b/frontend/src/pages/SightingDetailPage.tsx index bca0d57..93e45c9 100644 --- a/frontend/src/pages/SightingDetailPage.tsx +++ b/frontend/src/pages/SightingDetailPage.tsx @@ -1,3 +1,4 @@ +import { CombinedGraphQLErrors } from '@apollo/client' import { useEffect } from 'react' import { Link, useLocation, useParams } from 'react-router' import SightingMap from '../components/SightingMap' @@ -6,6 +7,7 @@ import { formatDate, formatDuration, formatLocation, + formatShape, } from '../utils/format-sighting' import './SightingDetailPage.css' @@ -27,11 +29,15 @@ function getFromSearch(state: unknown): string { } function SightingDetailPage() { - const { id } = useParams() - const sighting = useSighting(id) + const { id = '' } = useParams() + const { sighting, loading, error, retry } = useSighting(id) const location = useLocation() const backTo = { pathname: '/', search: getFromSearch(location.state) } - const title = sighting ? formatLocation(sighting) : 'Sighting not found' + + let title = 'Sighting not found' + if (sighting) title = formatLocation(sighting) + else if (error) title = 'Could not load sighting' + else if (loading) title = 'Loading sighting…' // Route changes keep the list's scroll position, which would open the // detail page scrolled past its heading. @@ -58,7 +64,22 @@ function SightingDetailPage() {
{backLink}

{title}

-

There is no sighting with id “{id}”.

+ {error ? ( +
+ {CombinedGraphQLErrors.is(error) ? ( +

The server could not load this sighting. Try again later.

+ ) : ( + <> +

Check your connection and try again.

+ + + )} +
+ ) : ( + !loading &&

There is no sighting with id “{id}”.

+ )}
) } @@ -83,7 +104,7 @@ function SightingDetailPage() {
Shape
-
{sighting.shape}
+
{formatShape(sighting.shape)}
Duration
@@ -91,7 +112,9 @@ function SightingDetailPage() {
-

{sighting.comments}

+ {sighting.comments && ( +

{sighting.comments}

+ )} ) } diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx index 920dfe8..9491a77 100644 --- a/frontend/src/pages/SubmitSightingPage.tsx +++ b/frontend/src/pages/SubmitSightingPage.tsx @@ -2,10 +2,8 @@ import { CombinedGraphQLErrors } from '@apollo/client' import { useMutation } from '@apollo/client/react' import { useState, type FormEvent } from 'react' import { Link } from 'react-router' -import { addSighting } from '../data/sighting-store' import { CREATE_SIGHTING_MUTATION, - type CreatedSighting, type SightingFieldError, } from '../graphql/mutations' import LocationPicker from '../components/LocationPicker' @@ -91,30 +89,15 @@ function serverFieldErrors(error: unknown): FormErrors | null { return errors } -// The API allows nulls for imported rows, but a submitted sighting always -// has every field except state, so the fallbacks are only for type safety. -function toUfoSighting(created: CreatedSighting) { - return { - id: created.id, - datetime: created.datetime ?? '', - city: created.city ?? '', - state: created.state ?? '', - country: created.country ?? '', - shape: created.shape ?? '', - durationSeconds: created.durationSeconds ?? 0, - comments: created.comments ?? '', - datePosted: created.datePosted ?? '', - latitude: created.latitude ?? 0, - longitude: created.longitude ?? 0, - } -} - function SubmitSightingPage() { const [values, setValues] = useState(initialValues) const [errors, setErrors] = useState({}) 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 })) @@ -135,6 +118,9 @@ function SubmitSightingPage() { try { const { data } = await createSighting({ + // The cached lists don't contain the new sighting. Dropping them + // makes the search page fetch fresh results when it is opened. + update: (cache) => cache.evict({ fieldName: 'sightings' }), variables: { input: { // datetime-local gives local time without timezone, which is @@ -152,7 +138,6 @@ function SubmitSightingPage() { }, }) if (!data) throw new Error('createSighting returned no data') - addSighting(toUfoSighting(data.createSighting)) setSubmittedId(data.createSighting.id) } catch (error) { const fieldErrors = serverFieldErrors(error) @@ -444,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 9d32c26..ea23cf1 100644 --- a/frontend/src/tests/SearchPage.test.tsx +++ b/frontend/src/tests/SearchPage.test.tsx @@ -2,9 +2,9 @@ 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 { mockSightings } from './mock-sightings' import { createCache } from '../graphql/client' import type { SightingsQueryVariables } from '../graphql/queries' import SearchPage from '../pages/SearchPage' @@ -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/SightingDetailPage.test.tsx b/frontend/src/tests/SightingDetailPage.test.tsx index 8cffd01..3fcf2a8 100644 --- a/frontend/src/tests/SightingDetailPage.test.tsx +++ b/frontend/src/tests/SightingDetailPage.test.tsx @@ -1,7 +1,14 @@ -import { render, screen } from '@testing-library/react' +import { + MockedProvider, + type MockedProviderProps, +} from '@apollo/client/testing/react' +import { GraphQLError } from 'graphql' +import { fireEvent, render, screen } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' +import { SIGHTING_QUERY } from '../graphql/queries' import SightingDetailPage from '../pages/SightingDetailPage' +import { mockSightings } from './mock-sightings' vi.mock('../components/SightingMap', () => ({ default: ({ @@ -19,13 +26,26 @@ vi.mock('../components/SightingMap', () => ({ ), })) -function renderDetailPage(path: string, state?: unknown) { +const found = { + request: { query: SIGHTING_QUERY, variables: { id: '1' } }, + result: { + data: { sighting: { __typename: 'Sighting', ...mockSightings[0] } }, + }, +} + +function renderDetailPage( + path: string, + mocks: MockedProviderProps['mocks'], + state?: unknown, +) { return render( - - - } /> - - , + + + + } /> + + + , ) } @@ -34,12 +54,15 @@ describe('SightingDetailPage', () => { vi.restoreAllMocks() }) - it('renders sighting details and preserves the originating search link', () => { + it('renders sighting details and preserves the originating search link', async () => { vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) - renderDetailPage('/sightings/1', { fromSearch: '?q=San+Marcos' }) + renderDetailPage('/sightings/1', [found], { fromSearch: '?q=San+Marcos' }) expect( - screen.getByRole('heading', { name: 'San Marcos, TX, US' }), + screen.getByRole('heading', { name: 'Loading sighting…' }), + ).toBeInTheDocument() + expect( + await screen.findByRole('heading', { name: 'San Marcos, TX, US' }), ).toBeInTheDocument() expect(screen.getByText('45 min')).toBeInTheDocument() expect(screen.getByText(/This event took place/)).toBeInTheDocument() @@ -54,12 +77,49 @@ describe('SightingDetailPage', () => { expect(document.title).toBe('San Marcos, TX, US · UFOund it') }) - it('handles missing sightings and links back to the unfiltered list', () => { + it('shows fallbacks for a missing shape and hides missing comments', async () => { vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) - renderDetailPage('/sightings/missing') + const sighting = { + ...found.result.data.sighting, + shape: null, + comments: null, + } + renderDetailPage('/sightings/1', [ + { ...found, result: { data: { sighting } } }, + ]) expect( - screen.getByRole('heading', { name: 'Sighting not found' }), + await screen.findByRole('heading', { name: 'San Marcos, TX, US' }), + ).toBeInTheDocument() + expect(screen.getByText('Unknown')).toBeInTheDocument() + expect(screen.queryByText(/This event took place/)).not.toBeInTheDocument() + }) + + it('does not suggest a retry when the server fails', async () => { + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + renderDetailPage('/sightings/1', [ + { ...found, result: { errors: [new GraphQLError('Database down')] } }, + ]) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'The server could not load this sighting', + ) + expect( + screen.queryByRole('button', { name: 'Try again' }), + ).not.toBeInTheDocument() + }) + + it('handles unknown ids and links back to the unfiltered list', async () => { + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + renderDetailPage('/sightings/missing', [ + { + request: { query: SIGHTING_QUERY, variables: { id: 'missing' } }, + result: { data: { sighting: null } }, + }, + ]) + + expect( + await screen.findByRole('heading', { name: 'Sighting not found' }), ).toBeInTheDocument() expect( screen.getByText('There is no sighting with id “missing”.'), @@ -70,4 +130,25 @@ describe('SightingDetailPage', () => { expect(screen.queryByTestId('detail-map')).not.toBeInTheDocument() expect(document.title).toBe('Sighting not found · UFOund it') }) + + it('shows an error and lets the user retry', async () => { + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + renderDetailPage('/sightings/1', [ + { ...found, error: new Error('Network down') }, + found, + ]) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Check your connection', + ) + expect( + screen.getByRole('heading', { name: 'Could not load sighting' }), + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + + expect( + await screen.findByRole('heading', { name: 'San Marcos, TX, US' }), + ).toBeInTheDocument() + }) }) diff --git a/frontend/src/tests/SightingList.test.tsx b/frontend/src/tests/SightingList.test.tsx index dc64f9d..a72d2fd 100644 --- a/frontend/src/tests/SightingList.test.tsx +++ b/frontend/src/tests/SightingList.test.tsx @@ -3,7 +3,7 @@ import type { ComponentProps } from 'react' import { MemoryRouter } from 'react-router' import { describe, expect, it, vi } from 'vitest' import SightingList from '../components/SightingList' -import { mockSightings } from '../data/mock-sightings' +import { mockSightings } from './mock-sightings' vi.mock('../components/SightingMap', () => ({ default: () =>
, diff --git a/frontend/src/tests/SightingMap.test.tsx b/frontend/src/tests/SightingMap.test.tsx index e502d86..51e2f80 100644 --- a/frontend/src/tests/SightingMap.test.tsx +++ b/frontend/src/tests/SightingMap.test.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import SightingMap from '../components/SightingMap' -import { mockSightings } from '../data/mock-sightings' +import { mockSightings } from './mock-sightings' type MapProps = { children: ReactNode diff --git a/frontend/src/tests/SubmitSightingPage.test.tsx b/frontend/src/tests/SubmitSightingPage.test.tsx index 3c9ba69..b7080be 100644 --- a/frontend/src/tests/SubmitSightingPage.test.tsx +++ b/frontend/src/tests/SubmitSightingPage.test.tsx @@ -1,19 +1,29 @@ +import { ApolloProvider } from '@apollo/client/react' import { MockedProvider, type MockedProviderProps, } from '@apollo/client/testing/react' +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 { getSightings } from '../data/sighting-store' +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', @@ -42,9 +52,12 @@ const created = { longitude: 10.39, } -function renderPage(mocks: MockedProviderProps['mocks']) { +function renderPage( + mocks: MockedProviderProps['mocks'], + cache?: InMemoryCache, +) { return render( - + @@ -53,6 +66,11 @@ function renderPage(mocks: MockedProviderProps['mocks']) { } 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], @@ -66,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', () => { @@ -86,7 +159,34 @@ describe('SubmitSightingPage', () => { expect( screen.getByRole('link', { name: 'View your sighting' }), ).toHaveAttribute('href', '/sightings/90001') - expect(getSightings()[0]).toMatchObject({ id: '90001', state: '' }) + }) + + it('drops the cached sighting lists so the new one shows up', async () => { + const cache = new InMemoryCache() + cache.writeQuery({ + query: SIGHTINGS_QUERY, + data: { + sightings: { + edges: [], + pageInfo: { hasNextPage: false, endCursor: null }, + totalCount: 0, + }, + }, + }) + renderPage( + [ + { + request: { query: CREATE_SIGHTING_MUTATION, variables: { input } }, + result: { data: { createSighting: created } }, + }, + ], + cache, + ) + + fillForm() + + await screen.findByRole('heading', { name: 'Sighting submitted' }) + expect(cache.extract().ROOT_QUERY).not.toHaveProperty('sightings') }) it('shows field errors returned by the server next to the field', async () => { @@ -137,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/frontend/src/tests/format-sighting.test.ts b/frontend/src/tests/format-sighting.test.ts index 10bf2a7..8305525 100644 --- a/frontend/src/tests/format-sighting.test.ts +++ b/frontend/src/tests/format-sighting.test.ts @@ -36,6 +36,7 @@ describe('sighting formatters', () => { it('falls back to readable text when the API returns null', () => { expect(formatDate(null)).toBe('Unknown date') + expect(formatDate('not a date')).toBe('Unknown date') expect(formatDuration(null)).toBe('Unknown') expect(formatShape(null)).toBe('Unknown') expect(formatLocation({ ...sighting, city: null })).toBe( diff --git a/frontend/src/tests/hooks.test.tsx b/frontend/src/tests/hooks.test.tsx index 551b733..5998124 100644 --- a/frontend/src/tests/hooks.test.tsx +++ b/frontend/src/tests/hooks.test.tsx @@ -1,8 +1,6 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { mockSightings } from '../data/mock-sightings' import { useDebouncedValue } from '../hooks/use-debounced-value' -import { useSighting } from '../hooks/use-sighting' describe('sighting hooks', () => { afterEach(() => { @@ -23,17 +21,4 @@ describe('sighting hooks', () => { act(() => vi.advanceTimersByTime(1)) expect(result.current).toBe('second') }) - - it('looks up an existing sighting and returns undefined for an unknown id', () => { - const { result, rerender } = renderHook(({ id }) => useSighting(id), { - initialProps: { id: mockSightings[0].id as string | undefined }, - }) - expect(result.current).toEqual(mockSightings[0]) - - rerender({ id: 'not-a-sighting' }) - expect(result.current).toBeUndefined() - - rerender({ id: undefined }) - expect(result.current).toBeUndefined() - }) }) diff --git a/frontend/src/data/mock-sightings.ts b/frontend/src/tests/mock-sightings.ts similarity index 95% rename from frontend/src/data/mock-sightings.ts rename to frontend/src/tests/mock-sightings.ts index a020365..18200ac 100644 --- a/frontend/src/data/mock-sightings.ts +++ b/frontend/src/tests/mock-sightings.ts @@ -1,10 +1,6 @@ import type { UfoSighting } from '../types/ufo-sighting' -/** - * Hand-written sample sightings. The search page now reads from GraphQL, - * but the detail page still looks sightings up here, and tests use them as - * fixtures. Remove from the app once the detail page uses GraphQL. - */ +/** Hand-written sample sightings, used as fixtures by the tests. */ export const mockSightings: UfoSighting[] = [ { id: '1', diff --git a/frontend/src/utils/format-sighting.ts b/frontend/src/utils/format-sighting.ts index c5a09d9..73fcade 100644 --- a/frontend/src/utils/format-sighting.ts +++ b/frontend/src/utils/format-sighting.ts @@ -3,9 +3,11 @@ import type { UfoSighting } from '../types/ufo-sighting' export function formatDate(date: string | null) { if (!date) return 'Unknown date' - return new Intl.DateTimeFormat('en', { - dateStyle: 'medium', - }).format(new Date(date)) + // Intl throws a RangeError for an invalid date. + const parsed = new Date(date) + if (Number.isNaN(parsed.getTime())) return 'Unknown date' + + return new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(parsed) } export function formatDuration(seconds: number | null) { 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 ." },