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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
129 changes: 129 additions & 0 deletions backend/src/schema.db.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<Page> {
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<string, unknown>, 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)
})
})
193 changes: 193 additions & 0 deletions backend/src/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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<string, any> // 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)
})
})