diff --git a/AGENTS.md b/AGENTS.md index 339a4d8..69bd5bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,10 @@ npm run format:check # No running backend or database is needed; component tests mock Apollo. npm run test +# Run the API tests against the real MySQL database (needs backend/.env). +# They only read, and skip themselves when no database is configured. +cd backend && npx tsx --env-file=.env --test src/schema.db.test.ts + # Re-run frontend tests on every file change while developing npm run test:watch diff --git a/backend/src/schema.db.test.ts b/backend/src/schema.db.test.ts new file mode 100644 index 0000000..bbaf46a --- /dev/null +++ b/backend/src/schema.db.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict' +import { after, describe, it } from 'node:test' +import { ApolloServer } from '@apollo/server' +import { pool } from './db/pool.ts' +import { resolvers, typeDefs } from './schema.ts' + +// These tests run the real queries against a real +// database, to check what schema.test.ts cannot: case-insensitive search and +// sorting and paging across the whole result set. They skip themselves when no database is configured, which +// keeps `npm run test` working without MySQL (and in CI). To run them: +// npx tsx --env-file=.env --test src/schema.db.test.ts +const hasDatabase = Boolean(process.env.DB_NAME) + +type Node = { id: string; datetime: string | null; shape: string | null } +type Page = { + totalCount: number + pageInfo: { hasNextPage: boolean; endCursor: string | null } + edges: { node: Node }[] +} + +const server = new ApolloServer({ typeDefs, resolvers }) + +async function sightings(variables: Record): Promise { + const response = await server.executeOperation({ + query: ` + query ($search: String, $filter: SightingFilter, $sort: SightingSort, + $first: Int, $after: String) { + sightings(search: $search, filter: $filter, sort: $sort, + first: $first, after: $after) { + totalCount + pageInfo { hasNextPage endCursor } + edges { node { id datetime shape } } + } + }`, + variables, + }) + assert.equal(response.body.kind, 'single') + const { data, errors } = response.body.singleResult + assert.equal(errors, undefined) + return (data as { sightings: Page }).sightings +} + +const ids = (page: Page) => page.edges.map((edge) => edge.node.id) + +// Follows the cursors for a few pages, like the client's "load more". +async function loadPages(variables: Record, pages: number) { + const loaded: string[] = [] + let after: string | null = null + for (let i = 0; i < pages; i++) { + const page: Page = await sightings({ ...variables, after }) + loaded.push(...ids(page)) + after = page.pageInfo.endCursor + } + return loaded +} + +describe('sightings against the real database', { skip: !hasDatabase }, () => { + after(() => pool.end()) + + it('finds the same sightings whatever the case of the search', async () => { + const lower = await sightings({ search: 'light', first: 50 }) + const upper = await sightings({ search: 'LIGHT', first: 50 }) + const mixed = await sightings({ search: 'LiGhT', first: 50 }) + + assert.ok(lower.totalCount > 0, 'the dataset should contain "light"') + assert.equal(upper.totalCount, lower.totalCount) + assert.equal(mixed.totalCount, lower.totalCount) + assert.deepEqual(ids(upper), ids(lower)) + assert.deepEqual(ids(mixed), ids(lower)) + }) + + it('returns fewer results when the search is narrower', async () => { + const all = await sightings({ first: 1 }) + const some = await sightings({ search: 'light', first: 1 }) + const none = await sightings({ search: 'zzzzqqqq-no-such-word', first: 1 }) + + assert.ok(some.totalCount < all.totalCount) + assert.equal(none.totalCount, 0) + assert.deepEqual(none.edges, []) + }) + + it('only returns sightings that match the filter', async () => { + const page = await sightings({ filter: { shape: 'light' }, first: 50 }) + + assert.ok(page.edges.length > 0) + for (const { node } of page.edges) { + assert.equal(node.shape?.toLowerCase(), 'light') + } + }) + + for (const sort of ['DATE_DESC', 'DATE_ASC', 'CITY_ASC', 'SHAPE_ASC']) { + it(`gives the same order paging as in one big page (${sort})`, async () => { + const onePage = await sightings({ sort, first: 60 }) + const paged = await loadPages({ sort, first: 20 }, 3) + + assert.deepEqual(paged, ids(onePage)) + assert.equal(new Set(paged).size, paged.length, 'no duplicates') + }) + } + + it('sorts by date across page boundaries', async () => { + const dates: string[] = [] + let after: string | null = null + for (let i = 0; i < 3; i++) { + const page: Page = await sightings({ + sort: 'DATE_ASC', + first: 20, + after, + }) + dates.push(...page.edges.map((edge) => edge.node.datetime ?? '')) + after = page.pageInfo.endCursor + } + + // ISO 8601 strings sort the same as the dates they describe. + assert.deepEqual(dates, [...dates].sort()) + }) + + it('reports the end of the results', async () => { + const total = (await sightings({ search: 'light', first: 1 })).totalCount + const last = await sightings({ + search: 'light', + first: 1, + after: Buffer.from(String(total - 1)).toString('base64'), + }) + + assert.equal(last.edges.length, 1) + assert.equal(last.pageInfo.hasNextPage, false) + }) +}) diff --git a/backend/src/schema.test.ts b/backend/src/schema.test.ts new file mode 100644 index 0000000..be8611c --- /dev/null +++ b/backend/src/schema.test.ts @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' +import { ApolloServer } from '@apollo/server' +import { pool } from './db/pool.ts' +import { resolvers, typeDefs } from './schema.ts' + +// The queries run through the real schema and resolvers, but the database +// is replaced by a fake so the tests need no MySQL. The fake records the +// SQL and parameters it receives. That checks what we ask the database +// for, not what MySQL does with it: case-insensitive LIKE comes from the +// column collations, so it cannot be tested here. +type Call = { sql: string; params: unknown[] } +let calls: Call[] + +function sightingRow(id: number) { + return { + id, + datetime: '2005-01-01 00:30:00', + city: 'Trondheim', + state: null, + country: 'no', + shape: 'light', + durationSeconds: 120, + comments: 'Bright light.', + datePosted: '2005-02-01 00:00:00', + latitude: 63.43, + longitude: 10.39, + userSubmitted: 0, + } +} + +function fakeDatabase(rows: unknown[], total = rows.length) { + mock.method(pool, 'query', (sql: string, params: unknown[] = []) => { + calls.push({ sql, params }) + return Promise.resolve(sql.includes('COUNT(*)') ? [[{ total }]] : [rows]) + }) +} + +const server = new ApolloServer({ typeDefs, resolvers }) + +async function run(query: string, variables?: Record) { + const response = await server.executeOperation({ query, variables }) + assert.equal(response.body.kind, 'single') + const { data, errors } = response.body.singleResult + assert.equal(errors, undefined) + // JSON round trip drops the null prototype so deepEqual compares plain objects. + return JSON.parse(JSON.stringify(data)) as Record // eslint-disable-line @typescript-eslint/no-explicit-any +} + +const SIGHTINGS = ` + query ($search: String, $sort: SightingSort, $first: Int, $after: String) { + sightings(search: $search, sort: $sort, first: $first, after: $after) { + totalCount + pageInfo { hasNextPage endCursor } + edges { cursor node { id } } + } + }` + +const listCall = () => calls.find((call) => !call.sql.includes('COUNT(*)'))! + +beforeEach(() => { + calls = [] +}) +afterEach(() => { + mock.restoreAll() +}) + +describe('sightings search', () => { + it('matches city, shape and comments with a trimmed pattern', async () => { + fakeDatabase([]) + await run(SIGHTINGS, { search: ' Light ' }) + + const { sql, params } = listCall() + assert.match( + sql, + /WHERE \(l\.CITY LIKE \? OR sh\.NAME LIKE \? OR s\.COMMENTS LIKE \?\)/, + ) + assert.deepEqual(params.slice(0, 3), ['%Light%', '%Light%', '%Light%']) + }) + + it('does not filter when the search is empty or blank', async () => { + fakeDatabase([]) + await run(SIGHTINGS, { search: ' ' }) + + assert.doesNotMatch(listCall().sql, /WHERE/) + }) +}) + +describe('sightings sort', () => { + const orders = { + DATE_DESC: 's.SIGHTING_DATETIME DESC', + DATE_ASC: 's.SIGHTING_DATETIME ASC', + CITY_ASC: 'l.CITY ASC', + SHAPE_ASC: 'sh.NAME ASC', + } + + for (const [sort, clause] of Object.entries(orders)) { + it(`orders by ${sort} in the database`, async () => { + fakeDatabase([]) + await run(SIGHTINGS, { sort }) + + assert.ok(listCall().sql.includes(`ORDER BY ${clause}`)) + }) + } + + it('sorts newest first by default', async () => { + fakeDatabase([]) + await run(SIGHTINGS) + + assert.ok(listCall().sql.includes('ORDER BY s.SIGHTING_DATETIME DESC')) + }) +}) + +describe('sightings pagination', () => { + it('returns a page with the total across all pages', async () => { + fakeDatabase([sightingRow(1), sightingRow(2)], 5) + const { sightings } = await run(SIGHTINGS, { first: 2 }) + + assert.equal(sightings.totalCount, 5) + assert.deepEqual( + sightings.edges.map((edge: { node: { id: string } }) => edge.node.id), + ['1', '2'], + ) + assert.equal(sightings.pageInfo.hasNextPage, true) + assert.equal(sightings.pageInfo.endCursor, sightings.edges[1].cursor) + }) + + it('continues from the cursor and ends on the last page', async () => { + fakeDatabase([sightingRow(1), sightingRow(2)], 3) + const first = await run(SIGHTINGS, { first: 2 }) + + calls = [] + mock.restoreAll() + fakeDatabase([sightingRow(3)], 3) + const { sightings } = await run(SIGHTINGS, { + first: 2, + after: first.sightings.pageInfo.endCursor, + }) + + assert.deepEqual(listCall().params.slice(-2), [2, 2]) // LIMIT 2 OFFSET 2 + assert.equal(sightings.pageInfo.hasNextPage, false) + }) + + it('keeps the page size between 1 and 100', async () => { + fakeDatabase([]) + await run(SIGHTINGS, { first: 500 }) + await run(SIGHTINGS, { first: 0 }) + + const limits = calls + .filter((call) => !call.sql.includes('COUNT(*)')) + .map((call) => call.params.at(-2)) + assert.deepEqual(limits, [100, 1]) + }) + + it('converts rows to the API shape', async () => { + fakeDatabase([sightingRow(7)]) + const { sightings } = await run(`{ + sightings { edges { node { id datetime userSubmitted } } } + }`) + + assert.deepEqual(sightings.edges[0].node, { + id: '7', + datetime: '2005-01-01T00:30:00', + userSubmitted: false, + }) + }) +}) + +describe('sighting', () => { + const SIGHTING = `query ($id: ID!) { sighting(id: $id) { id city } }` + + it('returns the sighting with that id', async () => { + fakeDatabase([sightingRow(42)]) + const { sighting } = await run(SIGHTING, { id: '42' }) + + assert.deepEqual(sighting, { id: '42', city: 'Trondheim' }) + }) + + it('returns null for an id that does not exist', async () => { + fakeDatabase([]) + const { sighting } = await run(SIGHTING, { id: '999999' }) + + assert.equal(sighting, null) + }) + + it('returns null without asking the database for a non-numeric id', async () => { + fakeDatabase([sightingRow(1)]) + const { sighting } = await run(SIGHTING, { id: '1abc' }) + + assert.equal(sighting, null) + assert.equal(calls.length, 0) + }) +})