Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
```
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/pages/SubmitSightingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ function SubmitSightingPage() {
const [submittedId, setSubmittedId] = useState<string>()
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 }))
Expand Down Expand Up @@ -426,7 +429,7 @@ function SubmitSightingPage() {
</p>
)}
</div>
{Object.keys(errors).length > 0 && (
{hasErrors && (
<p className="submit-sighting__summary" role="alert">
Please correct the highlighted fields.
</p>
Expand Down
64 changes: 60 additions & 4 deletions frontend/src/tests/SearchPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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) => {
Expand Down Expand Up @@ -61,7 +65,7 @@ function renderSearchPage(
totalCount: matches.length,
},
},
})
}).pipe(delay(latencyMs))
})
const client = new ApolloClient({ link, cache: createCache() })

Expand Down Expand Up @@ -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)
})
})
})
151 changes: 146 additions & 5 deletions frontend/src/tests/SubmitSightingPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="location-picker" />,
}))
vi.mock('../components/SightingMap', () => ({
default: () => <div data-testid="sighting-map" />,
}))

const input = {
datetime: '2024-03-10T21:15',
Expand Down Expand Up @@ -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],
Expand All @@ -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(
<ApolloProvider client={client}>
<MemoryRouter initialEntries={[initialEntry]}>
<Banner />
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/submit" element={<SubmitSightingPage />} />
</Routes>
</MemoryRouter>
</ApolloProvider>,
)
return operations
}

describe('SubmitSightingPage', () => {
Expand Down Expand Up @@ -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()
})
})
})
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"name": "t25-project-2",
"private": true,
"engines": {
"node": ">=22"
},
"workspaces": [
"frontend",
"backend"
Expand All @@ -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 ."
},
Expand Down