From 3a28fbd49a6e84341b81180e47f73e86b76b7428 Mon Sep 17 00:00:00 2001 From: "Andreas S. Steffensen" Date: Thu, 22 Oct 2026 15:34:51 +0200 Subject: [PATCH] feat: persist shared demo routes in PostgreSQL --- README.md | 4 + docs/saved-routes.md | 107 +++++ server/README.md | 2 + .../1792663200000_create-saved-routes.js | 66 +++ server/src/graphql/savedRoutes.ts | 69 +++ server/src/index.ts | 14 +- server/src/routes/savedRoutes.ts | 134 ++++++ server/tests/savedRoutes.test.ts | 422 ++++++++++++++++++ 8 files changed, 817 insertions(+), 1 deletion(-) create mode 100644 docs/saved-routes.md create mode 100644 server/migrations/1792663200000_create-saved-routes.js create mode 100644 server/src/graphql/savedRoutes.ts create mode 100644 server/src/routes/savedRoutes.ts create mode 100644 server/tests/savedRoutes.test.ts diff --git a/README.md b/README.md index 67464c7..b33e238 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ See [Venue V1](docs/venue-model.md) for the shared data contract and [Geoapify data import](docs/geoapify-data-import.md) for retrieval, validation and database import. Backend checks run on pull requests alongside frontend checks. +[Saved routes V1](docs/saved-routes.md) documents the initial shared-demo route +storage and API contract. Its migration/backend deployment and frontend +integration must be coordinated separately from the catalogue work. + ## Frontend deployment After a pull request is approved and merged into `main`, GitHub Actions runs diff --git a/docs/saved-routes.md b/docs/saved-routes.md new file mode 100644 index 0000000..92089bc --- /dev/null +++ b/docs/saved-routes.md @@ -0,0 +1,107 @@ +# Saved routes V1 + +This is the backend contract for the first database-backed route feature. +The initial scope is creating and listing routes for the existing shared demo +account, `ola-normann`. Favourites, route editing/deletion and authentication +are later work. All visitors currently share this demo account and its routes; +this feature does **not** provide private routes per individual visitor. + +## Persistence + +Migration `1792663200000_create-saved-routes.js` adds two tables without changing +existing Venue data: + +| Table | Fields | Purpose | +| ------------------- | --------------------------------------------------- | -------------------------------------------------------------------- | +| `saved_routes` | `id` (UUID), `user_id` (text), `name`, `created_at` | Stable route identity and ownership. | +| `saved_route_stops` | `route_id`, `venue_id`, `position` | Ordered references to existing Venue UUIDs. Positions start at zero. | + +Names are trimmed and contain 1–30 printable characters. A route contains +2–100 distinct venues. The minimum and name limit match the existing frontend; +the upper stop limit bounds the work performed by a single mutation. Every +venue must exist. Creation validates all stops and commits the route and its +stops in one transaction, rolling everything back on failure. + +The database enforces nonempty owners/names, unique positions and venues per +route, nonnegative positions and Venue references. The minimum/maximum number +of stops is enforced by the creation service. Deleting a route cascades to its +stops; deleting a referenced venue is restricted to preserve saved routes. + +The owner index supports newest-first pages ordered by `created_at DESC, id +DESC`. `user_id` is an opaque server-selected identity; there is no account table +or authentication dependency in V1. Once real accounts are introduced, derive +the owner from the verified user/session and plan any identity migration. Do +not accept a caller-supplied owner as proof of identity. + +## GraphQL + +The route SDL and resolvers live in `server/src/graphql/savedRoutes.ts`, separate +from the Venue schema. `server/src/index.ts` combines both modules. This keeps +the catalogue work independent of the route implementation. + +```graphql +query MyRoutes($offset: Int! = 0, $limit: Int! = 10) { + myRoutes(offset: $offset, limit: $limit) { + totalCount + items { + id + name + stopIds + createdAt + } + } +} + +mutation CreateRoute($input: CreateRouteInput!) { + createRoute(input: $input) { + id + name + stopIds + createdAt + } +} +``` + +`CreateRouteInput` contains `name: String!` and `stopIds: [ID!]!`. Obtain actual +Venue IDs from `venues` and pass them in visit order. Both operations use the +server-owned demo identity; the input has no `userId` field. `createdAt` is an +ISO 8601 UTC timestamp. Route IDs remain stable across reads and API restarts. + +`myRoutes` defaults to offset 0 and limit 10, with a maximum limit of 100. +`totalCount` counts only the current owner's routes, independently of the page. +Unknown owners have an empty result. Invalid route data, missing venues and +invalid page bounds return GraphQL `BAD_USER_INPUT` errors. Database failures +are errors, not successful local-only saves. + +## Frontend integration (Kasper) + +The existing `SavedRoute` fields `id`, `name`, `stopIds` are retained. Replace +the `localStorage` implementation in `client/src/api/routes.ts` with these +operations. Extract the `items` from `myRoutes` and support additional pages +when needed. Use `createRoute`'s returned ID and stop order rather than locally +generating an ID. Keep loading and error states; the frontend must report a +failed server save. Old example routes refer to mock slugs and should not be +sent as Venue UUIDs. + +No frontend behaviour is changed by this backend PR. Coordinate this contract +with Kasper before integrating the UI. The API module can be reviewed/merged +independently of the catalogue work and the shared map component. + +## Checks and deployment + +The normal backend typecheck, `test:data` and build include this feature. Tests +use an isolated PGlite database and cover migration reversal, validation, +ownership filtering, ordering, pagination, referential constraints, rollback +and the combined GraphQL schema. Recreating the API against the same database +verifies that routes are not kept in resolver memory. These tests do not prove +individual-user privacy or verify the live VM deployment. + +After review/merge, Aleks coordinates applying the new migration to the +existing VM database and deploying the matching backend release. Keep a backup +before changing the live schema. Do not reimport the Venue data to enable routes. +Then verify `myRoutes`, create a demo route using existing Venue IDs, and read it +again after restarting the API. The current frontend keeps using local storage +until its separate integration is deployed. + +Implementation, tests and this document were prepared with Codex. No live +database writes or VM migration are part of the local implementation checks. diff --git a/server/README.md b/server/README.md index f6e4580..e7bed69 100644 --- a/server/README.md +++ b/server/README.md @@ -5,6 +5,8 @@ and database run directly on the team VM, without Docker. - [Venue V1 contract](../docs/venue-model.md): fields, nulls, stable IDs and opening hours. - [Geoapify import workflow](../docs/geoapify-data-import.md): source retrieval, seed export and import. +- [Saved routes V1](../docs/saved-routes.md): shared demo routes, GraphQL contract, + database migration, tests and the separate frontend integration. ## Install and configure diff --git a/server/migrations/1792663200000_create-saved-routes.js b/server/migrations/1792663200000_create-saved-routes.js new file mode 100644 index 0000000..e73c3ba --- /dev/null +++ b/server/migrations/1792663200000_create-saved-routes.js @@ -0,0 +1,66 @@ +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +export const up = (pgm) => { + pgm.createTable('saved_routes', { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + // The demo identity is text today; a future authenticated identity can use + // the same owner column without rebuilding the route/stops model. + user_id: { type: 'text', notNull: true }, + name: { type: 'varchar(30)', notNull: true }, + created_at: { + type: 'timestamptz', + notNull: true, + default: pgm.func('current_timestamp'), + }, + }) + pgm.addConstraint('saved_routes', 'saved_routes_owner_nonempty', { + check: 'length(btrim(user_id)) > 0', + }) + pgm.addConstraint('saved_routes', 'saved_routes_name_nonempty', { + check: 'length(btrim(name)) > 0 AND name = btrim(name)', + }) + pgm.createIndex('saved_routes', ['user_id', 'created_at', 'id'], { + name: 'saved_routes_owner_created_idx', + }) + + pgm.createTable('saved_route_stops', { + route_id: { + type: 'uuid', + notNull: true, + references: 'saved_routes', + onDelete: 'CASCADE', + }, + venue_id: { + type: 'uuid', + notNull: true, + references: 'venues', + onDelete: 'RESTRICT', + }, + position: { type: 'integer', notNull: true }, + }) + pgm.addConstraint('saved_route_stops', 'saved_route_stops_pkey', { + primaryKey: ['route_id', 'position'], + }) + pgm.addConstraint('saved_route_stops', 'saved_route_stops_venue_unique', { + unique: ['route_id', 'venue_id'], + }) + pgm.addConstraint( + 'saved_route_stops', + 'saved_route_stops_position_nonnegative', + { + check: 'position >= 0', + }, + ) + pgm.createIndex('saved_route_stops', 'venue_id', { + name: 'saved_route_stops_venue_idx', + }) +} + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +export const down = (pgm) => { + pgm.dropTable('saved_route_stops') + pgm.dropTable('saved_routes') +} diff --git a/server/src/graphql/savedRoutes.ts b/server/src/graphql/savedRoutes.ts new file mode 100644 index 0000000..7f5de5f --- /dev/null +++ b/server/src/graphql/savedRoutes.ts @@ -0,0 +1,69 @@ +import { GraphQLError } from 'graphql' +import type { ImportDatabase, SqlExecutor } from '../data/importVenues.js' +import { + createSavedRoute, + listSavedRoutes, + RouteInputError, + type CreateRouteInput, + type RoutePageArguments, +} from '../routes/savedRoutes.js' + +// Shared demo account, matching the current frontend example user. This is +// deliberately not authentication or private routes per individual visitor. +export const DEMO_USER_ID = 'ola-normann' + +export const savedRouteTypeDefs = ` + type SavedRoute { + id: ID! + name: String! + stopIds: [ID!]! + createdAt: String! + } + + type SavedRoutePage { + items: [SavedRoute!]! + totalCount: Int! + } + + input CreateRouteInput { + name: String! + stopIds: [ID!]! + } + + extend type Query { + """Routes belonging to the shared demo account, newest first.""" + myRoutes(offset: Int! = 0, limit: Int! = 10): SavedRoutePage! + } + + type Mutation { + """Saves a route for the server-selected shared demo account.""" + createRoute(input: CreateRouteInput!): SavedRoute! + } +` + +async function routeOperation(operation: () => Promise) { + try { + return await operation() + } catch (error) { + if (error instanceof RouteInputError) + throw new GraphQLError(error.message, { + extensions: { code: 'BAD_USER_INPUT' }, + }) + throw error + } +} + +export const createSavedRouteResolvers = ( + database: SqlExecutor & ImportDatabase, +) => ({ + Query: { + myRoutes: (_parent: unknown, args: RoutePageArguments) => + routeOperation(() => listSavedRoutes(database, DEMO_USER_ID, args)), + }, + Mutation: { + createRoute: (_parent: unknown, args: { input: CreateRouteInput }) => + routeOperation(() => + createSavedRoute(database, DEMO_USER_ID, args.input), + ), + }, +}) diff --git a/server/src/index.ts b/server/src/index.ts index 5776b4a..59e20f5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,10 @@ import { ApolloServer } from '@apollo/server' import { startStandaloneServer } from '@apollo/server/standalone' import { pool } from './db/pool.js' import { createResolvers, typeDefs } from './graphql/schema.js' +import { + createSavedRouteResolvers, + savedRouteTypeDefs, +} from './graphql/savedRoutes.js' const port = Number(process.env.PORT ?? 3001) const host = process.env.HOST ?? '127.0.0.1' @@ -11,7 +15,15 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error('PORT must be a valid TCP port number.') } -const server = new ApolloServer({ typeDefs, resolvers: createResolvers(pool) }) +const venueResolvers = createResolvers(pool) +const routeResolvers = createSavedRouteResolvers(pool) +const server = new ApolloServer({ + typeDefs: [typeDefs, savedRouteTypeDefs], + resolvers: { + Query: { ...venueResolvers.Query, ...routeResolvers.Query }, + Mutation: routeResolvers.Mutation, + }, +}) try { const { url } = await startStandaloneServer(server, { diff --git a/server/src/routes/savedRoutes.ts b/server/src/routes/savedRoutes.ts new file mode 100644 index 0000000..d1d8dbd --- /dev/null +++ b/server/src/routes/savedRoutes.ts @@ -0,0 +1,134 @@ +import type { ImportDatabase, SqlExecutor } from '../data/importVenues.js' + +export const ROUTE_NAME_MAX_LENGTH = 30 +export const MIN_ROUTE_STOPS = 2 +export const MAX_ROUTE_STOPS = 100 +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export class RouteInputError extends Error { + constructor(message: string) { + super(message) + this.name = 'RouteInputError' + } +} + +export type CreateRouteInput = { name: string; stopIds: string[] } +export type SavedRoute = CreateRouteInput & { id: string; createdAt: string } +export type SavedRoutePage = { items: SavedRoute[]; totalCount: number } +export type RoutePageArguments = { offset?: number; limit?: number } + +export function validateRouteInput(payload: unknown): CreateRouteInput { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) + throw new RouteInputError('A route must contain a name and stopIds.') + + const { name, stopIds } = payload as Record + if ( + typeof name !== 'string' || + !name.trim() || + name.trim().length > ROUTE_NAME_MAX_LENGTH || + /[\u0000-\u001f\u007f]/.test(name) + ) + throw new RouteInputError( + 'Route names must contain 1 to 30 printable characters.', + ) + if ( + !Array.isArray(stopIds) || + stopIds.length < MIN_ROUTE_STOPS || + stopIds.length > MAX_ROUTE_STOPS + ) + throw new RouteInputError('Routes must contain between 2 and 100 stops.') + + const ids = stopIds.map((id) => { + if (typeof id !== 'string' || !UUID.test(id)) + throw new RouteInputError('Every stop must reference a Venue UUID.') + return id.toLowerCase() + }) + if (new Set(ids).size !== ids.length) + throw new RouteInputError('A venue may only appear once in a route.') + + return { name: name.trim(), stopIds: ids } +} + +function requireOwner(userId: string) { + if (typeof userId !== 'string' || !userId.trim() || userId !== userId.trim()) + throw new RouteInputError('A route owner is required.') +} + +function savedRoute(row: Record): SavedRoute { + return { + id: row.id as string, + name: row.name as string, + stopIds: row.stopIds as string[], + createdAt: new Date(row.createdAt as string | Date).toISOString(), + } +} + +/** The owner comes from server-side identity, never from the mutation input. */ +export async function createSavedRoute( + database: ImportDatabase, + userId: string, + payload: unknown, +): Promise { + requireOwner(userId) + const input = validateRouteInput(payload) + const client = await database.connect() + try { + await client.query('BEGIN') + // Keep referenced venues from being deleted between validation and insert. + const venues = await client.query( + 'SELECT id FROM venues WHERE id = ANY($1::uuid[]) FOR KEY SHARE', + [input.stopIds], + ) + if (venues.rows.length !== input.stopIds.length) + throw new RouteInputError('One or more route stops no longer exist.') + + const result = await client.query( + `INSERT INTO saved_routes (user_id, name) VALUES ($1, $2) + RETURNING id, name, created_at AS "createdAt"`, + [userId, input.name], + ) + const row = result.rows[0] + await client.query( + `INSERT INTO saved_route_stops (route_id, venue_id, position) + SELECT $1::uuid, venue_id, (ordinality - 1)::int + FROM unnest($2::uuid[]) WITH ORDINALITY AS stops(venue_id, ordinality)`, + [row.id, input.stopIds], + ) + await client.query('COMMIT') + return savedRoute({ ...row, stopIds: input.stopIds }) + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } +} + +export async function listSavedRoutes( + database: SqlExecutor, + userId: string, + { offset = 0, limit = 10 }: RoutePageArguments = {}, +): Promise { + requireOwner(userId) + if (!Number.isSafeInteger(offset) || offset < 0) + throw new RouteInputError('offset must be a nonnegative integer.') + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) + throw new RouteInputError('limit must be an integer between 1 and 100.') + + const count = await database.query( + 'SELECT COUNT(*)::int AS "totalCount" FROM saved_routes WHERE user_id = $1', + [userId], + ) + const result = await database.query( + `SELECT r.id, r.name, r.created_at AS "createdAt", + ARRAY(SELECT s.venue_id::text FROM saved_route_stops s + WHERE s.route_id = r.id ORDER BY s.position) AS "stopIds" + FROM saved_routes r WHERE r.user_id = $1 + ORDER BY r.created_at DESC, r.id DESC LIMIT $2 OFFSET $3`, + [userId, limit, offset], + ) + return { + items: result.rows.map(savedRoute), + totalCount: Number(count.rows[0]?.totalCount ?? 0), + } +} diff --git a/server/tests/savedRoutes.test.ts b/server/tests/savedRoutes.test.ts new file mode 100644 index 0000000..f82481a --- /dev/null +++ b/server/tests/savedRoutes.test.ts @@ -0,0 +1,422 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { ApolloServer } from '@apollo/server' +import { PGlite } from '@electric-sql/pglite' +import { MigrationBuilder } from 'node-pg-migrate' +import { up as createVenues } from '../migrations/1791904375317_create-venues.js' +import { up as completeVenues } from '../migrations/1791907200000_complete-venue-model.js' +import { + up as createRoutes, + down as dropRoutes, +} from '../migrations/1792663200000_create-saved-routes.js' +import type { ImportDatabase, SqlExecutor } from '../src/data/importVenues.js' +import { + createSavedRoute, + listSavedRoutes, + RouteInputError, + validateRouteInput, +} from '../src/routes/savedRoutes.js' +import { createResolvers, typeDefs } from '../src/graphql/schema.js' +import { + createSavedRouteResolvers, + DEMO_USER_ID, + savedRouteTypeDefs, +} from '../src/graphql/savedRoutes.js' + +const FIRST = 'a0000000-0000-0000-0000-000000000001' +const SECOND = 'b0000000-0000-0000-0000-000000000002' +const THIRD = 'c0000000-0000-0000-0000-000000000003' + +async function migrate( + db: PGlite, + migration: (builder: MigrationBuilder) => void | Promise, +) { + const builder = new MigrationBuilder( + {} as ConstructorParameters[0], + undefined, + false, + console, + ) + await migration(builder) + await db.exec(builder.getSql()) +} + +function adapter(db: PGlite): SqlExecutor & ImportDatabase { + return { + query: (sql, values) => db.query>(sql, values), + connect: async () => ({ + query: (sql, values) => db.query>(sql, values), + release() {}, + }), + } +} + +async function database() { + const db = new PGlite() + await migrate(db, createVenues) + await migrate(db, completeVenues) + await db.query( + `INSERT INTO venues (id, name, type, city, area, address, latitude, longitude) + SELECT id, 'Fixture venue', 'PUB', 'Trondheim', 'Midtbyen', 'Fixture 1', 63.43, 10.39 + FROM unnest($1::uuid[]) AS fixture(id)`, + [[FIRST, SECOND, THIRD]], + ) + await migrate(db, createRoutes) + return db +} + +function api(db: PGlite) { + const database = adapter(db) + const venues = createResolvers(database) + const routes = createSavedRouteResolvers(database) + return new ApolloServer({ + typeDefs: [typeDefs, savedRouteTypeDefs], + resolvers: { + Query: { ...venues.Query, ...routes.Query }, + Mutation: routes.Mutation, + }, + }) +} + +test('route input trims names, normalizes UUIDs and rejects invalid names, counts, IDs and duplicates', () => { + assert.deepEqual( + validateRouteInput({ + name: ' Elvekveld ', + stopIds: [SECOND.toUpperCase(), FIRST], + }), + { name: 'Elvekveld', stopIds: [SECOND, FIRST] }, + ) + const invalid: unknown[] = [ + null, + [], + {}, + { name: ' ', stopIds: [FIRST, SECOND] }, + { name: 'x'.repeat(31), stopIds: [FIRST, SECOND] }, + { name: 'Bad\u0000name', stopIds: [FIRST, SECOND] }, + { name: 'Route', stopIds: [] }, + { name: 'Route', stopIds: [FIRST] }, + { name: 'Route', stopIds: Array(101).fill(FIRST) }, + { name: 'Route', stopIds: [FIRST, 'mock-place-id'] }, + { name: 'Route', stopIds: [FIRST, null] }, + { name: 'Route', stopIds: [FIRST, FIRST.toUpperCase()] }, + ] + for (const payload of invalid) + assert.throws(() => validateRouteInput(payload), RouteInputError) +}) + +test('invalid input is rejected before a database connection is acquired', async () => { + let connections = 0 + const neverConnect: ImportDatabase = { + async connect() { + connections++ + throw new Error('Unexpected database connection') + }, + } + await assert.rejects( + createSavedRoute(neverConnect, DEMO_USER_ID, { + name: 'Route', + stopIds: [FIRST], + }), + RouteInputError, + ) + await assert.rejects( + createSavedRoute(neverConnect, '', { + name: 'Route', + stopIds: [FIRST, SECOND], + }), + RouteInputError, + ) + assert.equal(connections, 0) +}) + +test('saved route migration is additive and its reversal preserves Venue data', async () => { + const db = await database() + try { + assert.equal( + (await listSavedRoutes(adapter(db), DEMO_USER_ID)).totalCount, + 0, + ) + await createSavedRoute(adapter(db), DEMO_USER_ID, { + name: 'Route', + stopIds: [FIRST, SECOND], + }) + await migrate(db, dropRoutes) + const result = await db.query<{ routes: string | null; venues: number }>( + "SELECT to_regclass('saved_routes') AS routes, (SELECT COUNT(*)::int FROM venues) AS venues", + ) + assert.deepEqual(result.rows[0], { routes: null, venues: 3 }) + await migrate(db, createRoutes) + assert.equal( + (await listSavedRoutes(adapter(db), DEMO_USER_ID)).totalCount, + 0, + ) + } finally { + await db.close() + } +}) + +test('routes retain ordered Venue IDs and listing/counting is scoped to their server-selected owner', async () => { + const db = await database() + try { + const route = await createSavedRoute(adapter(db), DEMO_USER_ID, { + name: " Evening's route ", + stopIds: [THIRD, FIRST, SECOND], + }) + assert.equal(route.name, "Evening's route") + assert.match(route.id, /^[0-9a-f-]{36}$/) + assert.equal(new Date(route.createdAt).toISOString(), route.createdAt) + await createSavedRoute(adapter(db), 'another-user', { + name: 'Other route', + stopIds: [SECOND, THIRD], + }) + const own = await listSavedRoutes(adapter(db), DEMO_USER_ID) + assert.deepEqual(own, { totalCount: 1, items: [route] }) + assert.deepEqual(own.items[0].stopIds, [THIRD, FIRST, SECOND]) + const other = await listSavedRoutes(adapter(db), 'another-user') + assert.equal(other.totalCount, 1) + assert.equal(other.items[0].name, 'Other route') + assert.deepEqual(await listSavedRoutes(adapter(db), 'absent-user'), { + items: [], + totalCount: 0, + }) + assert.equal((await db.query('SELECT id FROM venues')).rows.length, 3) + } finally { + await db.close() + } +}) + +test('route pages have a deterministic order and a total count independent of page size or offset', async () => { + const db = await database() + try { + const first = await createSavedRoute(adapter(db), DEMO_USER_ID, { + name: 'First', + stopIds: [FIRST, SECOND], + }) + const second = await createSavedRoute(adapter(db), DEMO_USER_ID, { + name: 'Second', + stopIds: [FIRST, THIRD], + }) + await db.query('UPDATE saved_routes SET created_at = $1 WHERE id = $2', [ + '2026-10-20T10:00:00Z', + first.id, + ]) + await db.query('UPDATE saved_routes SET created_at = $1 WHERE id = $2', [ + '2026-10-21T10:00:00Z', + second.id, + ]) + const page1 = await listSavedRoutes(adapter(db), DEMO_USER_ID, { + limit: 1, + }) + const page2 = await listSavedRoutes(adapter(db), DEMO_USER_ID, { + offset: 1, + limit: 1, + }) + assert.equal(page1.items[0].id, second.id) + assert.equal(page2.items[0].id, first.id) + assert.equal(page1.totalCount, 2) + assert.equal(page2.totalCount, 2) + assert.deepEqual( + await listSavedRoutes(adapter(db), DEMO_USER_ID, { offset: 2 }), + { + totalCount: 2, + items: [], + }, + ) + for (const args of [ + { offset: -1 }, + { offset: 0.5 }, + { limit: 0 }, + { limit: 101 }, + ]) + await assert.rejects( + listSavedRoutes(adapter(db), DEMO_USER_ID, args), + RouteInputError, + ) + } finally { + await db.close() + } +}) + +test('missing venues and a mid-save failure leave no partial route and always release the connection', async () => { + const db = await database() + try { + await assert.rejects( + createSavedRoute(adapter(db), DEMO_USER_ID, { + name: 'Missing stop', + stopIds: [FIRST, '00000000-0000-0000-0000-000000000000'], + }), + /stops no longer exist/, + ) + let released = false + const failing: ImportDatabase = { + connect: async () => ({ + query: async (sql, values) => { + if (sql.includes('INSERT INTO saved_route_stops')) + throw new Error('Injected stop failure') + return db.query>(sql, values) + }, + release() { + released = true + }, + }), + } + await assert.rejects( + createSavedRoute(failing, DEMO_USER_ID, { + name: 'Rollback', + stopIds: [FIRST, SECOND], + }), + /Injected stop failure/, + ) + assert.equal(released, true) + assert.equal((await db.query('SELECT id FROM saved_routes')).rows.length, 0) + assert.equal( + (await db.query('SELECT route_id FROM saved_route_stops')).rows.length, + 0, + ) + const retry = await createSavedRoute(adapter(db), DEMO_USER_ID, { + name: 'Retry', + stopIds: [FIRST, SECOND], + }) + assert.equal( + (await listSavedRoutes(adapter(db), DEMO_USER_ID)).items[0].id, + retry.id, + ) + } finally { + await db.close() + } +}) + +test('database constraints protect stop order, references and route names', async () => { + const db = await database() + try { + const route = await createSavedRoute(adapter(db), DEMO_USER_ID, { + name: 'Route', + stopIds: [FIRST, SECOND], + }) + await assert.rejects( + db.query('UPDATE saved_routes SET name = $1', [' ']), + /check constraint/, + ) + await assert.rejects( + db.query('UPDATE saved_routes SET name = $1', ['x'.repeat(31)]), + /too long/, + ) + await assert.rejects( + db.query('UPDATE saved_routes SET user_id = $1', [' ']), + /check constraint/, + ) + await assert.rejects( + db.query( + 'INSERT INTO saved_route_stops (route_id, venue_id, position) VALUES ($1, $2, $3)', + [route.id, THIRD, 0], + ), + /duplicate key/, + ) + await assert.rejects( + db.query( + 'INSERT INTO saved_route_stops (route_id, venue_id, position) VALUES ($1, $2, $3)', + [route.id, FIRST, 2], + ), + /duplicate key/, + ) + await assert.rejects( + db.query( + 'INSERT INTO saved_route_stops (route_id, venue_id, position) VALUES ($1, $2, $3)', + [route.id, THIRD, -1], + ), + /check constraint/, + ) + await assert.rejects( + db.query('DELETE FROM venues WHERE id = $1', [FIRST]), + /foreign key/, + ) + await db.query('DELETE FROM saved_routes WHERE id = $1', [route.id]) + assert.equal( + (await db.query('SELECT route_id FROM saved_route_stops')).rows.length, + 0, + ) + } finally { + await db.close() + } +}) + +test('GraphQL saves demo routes, reloads them in a new API instance and rejects invalid input or client-supplied owners', async () => { + const db = await database() + let server = api(db) + const mutation = `mutation($input: CreateRouteInput!) { + createRoute(input: $input) { id name stopIds createdAt } + }` + try { + const created = await server.executeOperation({ + query: mutation, + variables: { input: { name: 'Elvekveld', stopIds: [SECOND, FIRST] } }, + }) + assert.equal(created.body.kind, 'single') + if (created.body.kind !== 'single') + throw new Error('Expected single response') + assert.equal(created.body.singleResult.errors, undefined) + const route = JSON.parse( + JSON.stringify(created.body.singleResult.data?.createRoute), + ) + assert.deepEqual(route.stopIds, [SECOND, FIRST]) + const owner = await db.query<{ user_id: string }>( + 'SELECT user_id FROM saved_routes', + ) + assert.equal(owner.rows[0].user_id, DEMO_USER_ID) + await createSavedRoute(adapter(db), 'another-user', { + name: 'Separate owner', + stopIds: [FIRST, THIRD], + }) + + await server.stop() + server = api(db) + const listed = await server.executeOperation({ + query: + '{ health myRoutes { totalCount items { id name stopIds createdAt } } }', + }) + assert.equal(listed.body.kind, 'single') + if (listed.body.kind !== 'single') + throw new Error('Expected single response') + assert.equal(listed.body.singleResult.errors, undefined) + assert.deepEqual( + JSON.parse(JSON.stringify(listed.body.singleResult.data)), + { + health: 'ok', + myRoutes: { totalCount: 1, items: [route] }, + }, + ) + + const invalid = await server.executeOperation({ + query: mutation, + variables: { input: { name: 'Invalid', stopIds: [FIRST] } }, + }) + assert.equal(invalid.body.kind, 'single') + if (invalid.body.kind !== 'single') + throw new Error('Expected single response') + assert.equal( + invalid.body.singleResult.errors?.[0].extensions?.code, + 'BAD_USER_INPUT', + ) + const spoofed = await server.executeOperation({ + query: mutation, + variables: { + input: { + name: 'Spoof', + stopIds: [FIRST, SECOND], + userId: 'another-user', + }, + }, + }) + assert.equal(spoofed.body.kind, 'single') + if (spoofed.body.kind !== 'single') + throw new Error('Expected single response') + assert.ok(spoofed.body.singleResult.errors?.length) + assert.equal( + (await listSavedRoutes(adapter(db), DEMO_USER_ID)).totalCount, + 1, + ) + } finally { + await server.stop() + await db.close() + } +})