diff --git a/README.md b/README.md index 3af5aa1..e45967f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ We are building a web application that helps gymnastics coaches plan their gymna ## Status -The first delivery was a specification. The application is not finished: the frontend is still the Vite starter page, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). The user data and the connection from the frontend are the next steps. +The first delivery was a specification. The application is not finished: the frontend is still the Vite starter page, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). The login and the user data (gymnasts, their lists of elements and routines) work through the API. The connection from the frontend is the next step. The code in `src/` is the Vite starter project with one example test. The tooling is set up: TypeScript, ESLint, Prettier and Vitest. @@ -312,7 +312,7 @@ To write your own SQL queries, connect with `psql -h localhost -U cop -d cop` (o ## GraphQL server -The server is in `backend/src/server/` (Apollo Server 5 on Express 5). It answers the catalog queries and the search through GraphQL. Login is built (see "Login" below). The user data (gymnasts, routines) is not; those operations answer a clear error, see [The server in docs/API.md](docs/API.md#the-server). +The server is in `backend/src/server/` (Apollo Server 5 on Express 5). It answers the catalog queries and the search through GraphQL. Login and the user data (gymnasts, routines) are built (see "Login" below). How the server works is in [The server in docs/API.md](docs/API.md#the-server). **Start it.** The database must be migrated and seeded first (see "Database (local setup)" above): diff --git a/backend/src/dscore.ts b/backend/src/dscore.ts index 2e38e07..797e502 100644 --- a/backend/src/dscore.ts +++ b/backend/src/dscore.ts @@ -10,7 +10,7 @@ // divided at the end. Adding 0.1 + 0.2 as decimals gives 0.30000000000000004. import { InvalidItemsError } from './errors.js' -import { apparatusOfId } from './server/items.js' +import { type Item, apparatusOfId } from './server/items.js' /** How many elements count in the score. */ export const COUNTED_ELEMENTS = 8 @@ -58,3 +58,9 @@ export function calculateDScore(items: ScoredItem[]): number { .slice(0, COUNTED_ELEMENTS) return highest.reduce((sum, tenths) => sum + tenths, 0) / 10 } + +/** An element or vault from the database as the function wants it. */ +export const toScoredItem = (item: Item): ScoredItem => + item.__typename === 'Vault' + ? { id: item.id, code: item.code, value: item.dValue, isVault: true } + : { id: item.id, code: item.code, value: item.value, isVault: false } diff --git a/backend/src/errors.ts b/backend/src/errors.ts index 1cd1105..dd0191d 100644 --- a/backend/src/errors.ts +++ b/backend/src/errors.ts @@ -53,18 +53,57 @@ export class InvalidItemsError extends Error { } } +/** + * The id does not exist, or it belongs to another coach. Both cases give the + * same answer, so that nobody can find out which ids exist. Code "NOT_FOUND". + */ +export class NotFoundError extends Error { + readonly code = 'NOT_FOUND' + + constructor(what = 'The item') { + super(`${what} was not found.`) + this.name = 'NotFoundError' + } +} + +/** The coach already has a routine with this name for this gymnast and apparatus. Code "NAME_TAKEN". */ +export class NameTakenError extends Error { + readonly code = 'NAME_TAKEN' + + constructor(message = 'A routine with this name already exists.') { + super(message) + this.name = 'NameTakenError' + } +} + +/** A list is full (gymnasts, routines, items in a routine or in a gymnast's list). Code "LIMIT_REACHED". */ +export class LimitReachedError extends Error { + readonly code = 'LIMIT_REACHED' + + constructor(message: string) { + super(message) + this.name = 'LimitReachedError' + } +} + /** The errors above, which the server shows to the client with their code. */ export type ClientError = | BadUserInputError | UnauthenticatedError | NotImplementedError | InvalidItemsError + | NotFoundError + | NameTakenError + | LimitReachedError export function isClientError(error: unknown): error is ClientError { return ( error instanceof BadUserInputError || error instanceof UnauthenticatedError || error instanceof NotImplementedError || - error instanceof InvalidItemsError + error instanceof InvalidItemsError || + error instanceof NotFoundError || + error instanceof NameTakenError || + error instanceof LimitReachedError ) } diff --git a/backend/src/server/auth.test.ts b/backend/src/server/auth.test.ts index 2dc4b5c..880fd31 100644 --- a/backend/src/server/auth.test.ts +++ b/backend/src/server/auth.test.ts @@ -171,7 +171,7 @@ describe.runIf(hasDatabase)('the session', () => { expect( codeOf(await run(query, undefined, { accessToken, now: minutes(20) })), ).toBe('UNAUTHENTICATED') - // With a valid token the login check passes (the operation itself is built in another issue). + // With a valid token the login check passes. expect(codeOf(await run(query, undefined, { accessToken }))).not.toBe( 'UNAUTHENTICATED', ) diff --git a/backend/src/server/items.ts b/backend/src/server/items.ts index b632ac0..bc67332 100644 --- a/backend/src/server/items.ts +++ b/backend/src/server/items.ts @@ -15,6 +15,9 @@ export type Item = ElementItem | VaultItem */ export const isVaultId = (id: string): boolean => id.startsWith('VT-') +/** Ids look like "UB-2.305-a". This keeps odd text out of the queries and the messages. */ +export const ITEM_ID_PATTERN = /^[A-Za-z0-9.-]{1,40}$/ + /** The apparatus an item belongs to: the first part of its id ("UB" in "UB-2.305-a"). */ export const apparatusOfId = (id: string): string => id.split('-')[0] ?? id diff --git a/backend/src/server/loaders.ts b/backend/src/server/loaders.ts index 3c0680f..52369fe 100644 --- a/backend/src/server/loaders.ts +++ b/backend/src/server/loaders.ts @@ -4,9 +4,23 @@ // between requests (see "N+1 queries" in docs/API.md). import DataLoader from 'dataloader' -import type { Apparatus, PrismaClient } from '../generated/prisma/client.js' +import type { + Apparatus, + Element, + Gymnast, + PrismaClient, + Routine, + Vault, +} from '../generated/prisma/client.js' import { type Item, asElement, asVault, isVaultId } from './items.js' +/** The element or the vault of a list row (exactly one of them is set). */ +const itemOf = (row: { + element: Element | null + vault: Vault | null +}): Item[] => + row.element ? [asElement(row.element)] : row.vault ? [asVault(row.vault)] : [] + export function createLoaders(prisma: PrismaClient) { /** An element or vault by its id, also when it is removed. `null` when it does not exist. */ const itemById = new DataLoader(async (ids) => { @@ -64,7 +78,60 @@ export function createLoaders(prisma: PrismaClient) { }, ) - return { itemById, variantsOfGroup, apparatusById } + const gymnastById = new DataLoader(async (ids) => { + const rows = await prisma.gymnast.findMany({ + where: { id: { in: [...ids] } }, + }) + const byId = new Map(rows.map((row) => [row.id, row])) + return ids.map((id) => byId.get(id) ?? null) + }) + + /** The routines of each gymnast: by apparatus, then by name. At most 50 each. */ + const routinesOfGymnast = new DataLoader(async (ids) => { + const rows = await prisma.routine.findMany({ + where: { gymnastId: { in: [...ids] } }, + orderBy: [ + { apparatus: { position: 'asc' } }, + { name: 'asc' }, + { id: 'asc' }, + ], + }) + return ids.map((id) => rows.filter((row) => row.gymnastId === id)) + }) + + /** The list of each gymnast, in the order the items were added. At most 100 each. */ + const elementsOfGymnast = new DataLoader(async (ids) => { + const rows = await prisma.gymnastElement.findMany({ + where: { gymnastId: { in: [...ids] } }, + orderBy: { id: 'asc' }, + include: { element: true, vault: true }, + }) + return ids.map((id) => + rows.filter((row) => row.gymnastId === id).flatMap(itemOf), + ) + }) + + /** The items of each routine, in the order of the routine. The same item can be there twice. */ + const itemsOfRoutine = new DataLoader(async (ids) => { + const rows = await prisma.routineElement.findMany({ + where: { routineId: { in: [...ids] } }, + orderBy: [{ routineId: 'asc' }, { position: 'asc' }], + include: { element: true, vault: true }, + }) + return ids.map((id) => + rows.filter((row) => row.routineId === id).flatMap(itemOf), + ) + }) + + return { + itemById, + variantsOfGroup, + apparatusById, + gymnastById, + routinesOfGymnast, + elementsOfGymnast, + itemsOfRoutine, + } } export type Loaders = ReturnType diff --git a/backend/src/server/resolvers/calculator.ts b/backend/src/server/resolvers/calculator.ts index d8706d6..3136d68 100644 --- a/backend/src/server/resolvers/calculator.ts +++ b/backend/src/server/resolvers/calculator.ts @@ -4,13 +4,11 @@ import { calculateDScore, type ScoredItem } from '../../dscore.js' import { BadUserInputError, InvalidItemsError } from '../../errors.js' import type { Resolvers } from '../../generated/graphql.js' import type { Context } from '../context.js' +import { ITEM_ID_PATTERN } from '../items.js' /** The most ids one call may have (also in docs/API.md). */ export const MAX_CALCULATOR_IDS = 50 -/** Ids look like "UB-2.305-a". This keeps odd text out of the queries and the messages. */ -const ID_PATTERN = /^[A-Za-z0-9.-]{1,40}$/ - export const calculatorResolvers = { Query: { calculateDScore: async ( @@ -23,7 +21,7 @@ export const calculatorResolvers = { `At most ${MAX_CALCULATOR_IDS} ids can be calculated at once.`, ) } - if (args.itemIds.some((id) => !ID_PATTERN.test(id))) { + if (args.itemIds.some((id) => !ITEM_ID_PATTERN.test(id))) { throw new BadUserInputError('An item id is not valid.') } const ids = [...new Set(args.itemIds)] // the same id twice counts once diff --git a/backend/src/server/resolvers/index.ts b/backend/src/server/resolvers/index.ts index 2a19fc9..41f68de 100644 --- a/backend/src/server/resolvers/index.ts +++ b/backend/src/server/resolvers/index.ts @@ -1,10 +1,26 @@ +import { GraphQLScalarType } from 'graphql' import { authResolvers } from './auth.js' import { calculatorResolvers } from './calculator.js' import { catalogResolvers } from './catalog.js' import { userDataResolvers } from './userData.js' +/** DateTime: an ISO 8601 date and time in the answer, a Date in the resolvers. */ +const DateTime = new GraphQLScalarType({ + name: 'DateTime', + serialize: (value) => { + if (!(value instanceof Date)) throw new TypeError('Expected a Date.') + return value.toISOString() + }, + parseValue: (value) => { + const date = new Date(String(value)) + if (Number.isNaN(date.getTime())) throw new TypeError('Not a date.') + return date + }, +}) + /** All the resolvers, merged by type. Add a file per area and merge it here. */ export const resolvers = { + DateTime, ...catalogResolvers, ...userDataResolvers, Query: { diff --git a/backend/src/server/resolvers/userData.ts b/backend/src/server/resolvers/userData.ts index dda25bd..4bb2619 100644 --- a/backend/src/server/resolvers/userData.ts +++ b/backend/src/server/resolvers/userData.ts @@ -1,44 +1,357 @@ -// The gymnasts, their lists of elements and the routines are not built yet -// (issue #73). Until they are, these operations answer UNAUTHENTICATED when -// nobody is logged in, and NOT_IMPLEMENTED (temporary) when somebody is, so the -// schema can be used as it is and nothing returns a confusing null. The login -// and the session are in auth.ts. +// The user data: gymnasts, their lists of elements, and routines (issue #73). +// The rules are in "User data" and "Limits" in docs/API.md: +// - every operation needs a login, and a coach only reaches their own data. +// Another coach's id gives NOT_FOUND, the same as an id that does not exist, +// so nobody can find out which ids exist +// - names are trimmed and 1-70 characters, the lists have limits +// - adding an item that is already in the gymnast's list, or removing one that +// is not, changes nothing and gives no error -import { NotImplementedError } from '../../errors.js' +import { calculateDScore, toScoredItem } from '../../dscore.js' +import { + BadUserInputError, + InvalidItemsError, + LimitReachedError, + NameTakenError, + NotFoundError, +} from '../../errors.js' import type { Resolvers } from '../../generated/graphql.js' +import { + type Gymnast, + Prisma, + type Routine, +} from '../../generated/prisma/client.js' +import { + MAX_ELEMENTS_PER_GYMNAST, + MAX_GYMNASTS_PER_COACH, + MAX_ITEMS_PER_ROUTINE, + MAX_ROUTINES_PER_GYMNAST, + cleanName, +} from '../../userData/rules.js' import { type Context, requireCoachId } from '../context.js' +import { ITEM_ID_PATTERN, apparatusOfId, isVaultId } from '../items.js' -/** For the operations that are not built yet: log in first, then NOT_IMPLEMENTED. */ -const notBuilt = - (operation: string) => - async ( - _parent: unknown, - _args: unknown, - context: Context, - ): Promise => { - await requireCoachId(context) - throw new NotImplementedError(operation) - } +/** A unique index was broken: two requests made the same thing at the same moment, or the name exists. */ +const isUniqueViolation = (error: unknown): boolean => + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + +/** The coach's own gymnast. Someone else's gymnast is NOT_FOUND, like one that does not exist. */ +async function ownGymnast( + context: Context, + coachId: string, + id: string, +): Promise { + const gymnast = await context.prisma.gymnast.findFirst({ + where: { id, coachId }, + }) + if (!gymnast) throw new NotFoundError('The gymnast') + return gymnast +} + +/** The coach's own routine (through its gymnast). */ +async function ownRoutine( + context: Context, + coachId: string, + id: string, +): Promise { + const routine = await context.prisma.routine.findFirst({ + where: { id, gymnast: { coachId } }, + }) + if (!routine) throw new NotFoundError('The routine') + return routine +} + +/** The loaders keep what they have loaded during one request. A change must make them forget it. */ +const forgetGymnast = (context: Context, gymnastId: string) => { + context.loaders.gymnastById.clear(gymnastId) + context.loaders.elementsOfGymnast.clear(gymnastId) + context.loaders.routinesOfGymnast.clear(gymnastId) +} + +const NAME_TAKEN = + 'The gymnast already has a routine with this name on this apparatus.' export const userDataResolvers = { Query: { - gymnast: notBuilt('gymnast'), - routine: notBuilt('routine'), + gymnast: async (_parent, args, context) => + ownGymnast(context, await requireCoachId(context), args.id), + + routine: async (_parent, args, context) => + ownRoutine(context, await requireCoachId(context), args.id), }, Coach: { - gymnasts: notBuilt('Coach.gymnasts'), + /** At most 200, see MAX_GYMNASTS_PER_COACH. */ + gymnasts: (coach, _args, context) => + context.prisma.gymnast.findMany({ + where: { coachId: coach.id }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + take: MAX_GYMNASTS_PER_COACH, + }), + }, + + Gymnast: { + routines: async (gymnast, args, context) => { + const routines = await context.loaders.routinesOfGymnast.load(gymnast.id) + return args.apparatusId + ? routines.filter((r) => r.apparatusId === args.apparatusId) + : routines + }, + + elements: async (gymnast, args, context) => { + const items = await context.loaders.elementsOfGymnast.load(gymnast.id) + return args.apparatusId + ? items.filter((item) => apparatusOfId(item.id) === args.apparatusId) + : items + }, + }, + + Routine: { + gymnast: async (routine, _args, context) => { + const gymnast = await context.loaders.gymnastById.load(routine.gymnastId) + if (!gymnast) throw new NotFoundError('The gymnast') // the foreign key makes this impossible + return gymnast + }, + + apparatus: async (routine, _args, context) => { + const apparatus = await context.loaders.apparatusById.load( + routine.apparatusId, + ) + if (!apparatus) throw new Error(`No apparatus ${routine.apparatusId}.`) + return apparatus + }, + + items: (routine, _args, context) => + context.loaders.itemsOfRoutine.load(routine.id), + + /** null while the routine has a removed item, like the calculator refuses one. */ + dScore: async (routine, _args, context) => { + const items = await context.loaders.itemsOfRoutine.load(routine.id) + if (items.some((item) => item.removedAt !== null)) return null + return calculateDScore(items.map(toScoredItem)) + }, }, Mutation: { - createGymnast: notBuilt('createGymnast'), - renameGymnast: notBuilt('renameGymnast'), - deleteGymnast: notBuilt('deleteGymnast'), - addGymnastElement: notBuilt('addGymnastElement'), - removeGymnastElement: notBuilt('removeGymnastElement'), - createRoutine: notBuilt('createRoutine'), - renameRoutine: notBuilt('renameRoutine'), - deleteRoutine: notBuilt('deleteRoutine'), - setRoutineItems: notBuilt('setRoutineItems'), + createGymnast: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const name = cleanName(args.name, 'gymnast') + const count = await context.prisma.gymnast.count({ where: { coachId } }) + if (count >= MAX_GYMNASTS_PER_COACH) { + throw new LimitReachedError( + `A coach can have at most ${MAX_GYMNASTS_PER_COACH} gymnasts.`, + ) + } + return context.prisma.gymnast.create({ data: { coachId, name } }) + }, + + renameGymnast: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + await ownGymnast(context, coachId, args.id) + const name = cleanName(args.name, 'gymnast') + const gymnast = await context.prisma.gymnast.update({ + where: { id: args.id }, + data: { name }, + }) + forgetGymnast(context, gymnast.id) + return gymnast + }, + + /** Also deletes the routines and the list (the database does it). Returns the id. */ + deleteGymnast: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + await ownGymnast(context, coachId, args.id) + await context.prisma.gymnast.delete({ where: { id: args.id } }) + forgetGymnast(context, args.id) + return args.id + }, + + addGymnastElement: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const gymnast = await ownGymnast(context, coachId, args.gymnastId) + if (!ITEM_ID_PATTERN.test(args.itemId)) { + throw new NotFoundError('The item') + } + const item = await context.loaders.itemById.load(args.itemId) + if (!item) throw new NotFoundError('The item') + + const row = + item.__typename === 'Vault' + ? { gymnastId: gymnast.id, vaultId: item.id } + : { gymnastId: gymnast.id, elementId: item.id } + // Already in the list: nothing to do, and no error. + const existing = await context.prisma.gymnastElement.findFirst({ + where: row, + }) + if (!existing) { + if (item.removedAt !== null) { + throw new InvalidItemsError( + `${item.id} is no longer in the Code of Points and cannot be added.`, + ) + } + const count = await context.prisma.gymnastElement.count({ + where: { gymnastId: gymnast.id }, + }) + if (count >= MAX_ELEMENTS_PER_GYMNAST) { + throw new LimitReachedError( + `A gymnast can have at most ${MAX_ELEMENTS_PER_GYMNAST} items in the list.`, + ) + } + try { + await context.prisma.gymnastElement.create({ data: row }) + } catch (error) { + // Two requests added the same item at the same moment: it is in the list. + if (!isUniqueViolation(error)) throw error + } + } + forgetGymnast(context, gymnast.id) + return gymnast + }, + + removeGymnastElement: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const gymnast = await ownGymnast(context, coachId, args.gymnastId) + // Also an item that has been removed from the Code of Points. An item that is not in the list does nothing. + await context.prisma.gymnastElement.deleteMany({ + where: isVaultId(args.itemId) + ? { gymnastId: gymnast.id, vaultId: args.itemId } + : { gymnastId: gymnast.id, elementId: args.itemId }, + }) + forgetGymnast(context, gymnast.id) + return gymnast + }, + + createRoutine: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const { gymnastId, apparatusId } = args.input + const gymnast = await ownGymnast(context, coachId, gymnastId) + if (!(await context.loaders.apparatusById.load(apparatusId))) { + throw new BadUserInputError('There is no apparatus with that id.') + } + const name = cleanName(args.input.name, 'routine') + const count = await context.prisma.routine.count({ + where: { gymnastId: gymnast.id }, + }) + if (count >= MAX_ROUTINES_PER_GYMNAST) { + throw new LimitReachedError( + `A gymnast can have at most ${MAX_ROUTINES_PER_GYMNAST} routines.`, + ) + } + try { + const routine = await context.prisma.routine.create({ + data: { gymnastId: gymnast.id, apparatusId, name }, + }) + forgetGymnast(context, gymnast.id) + return routine + } catch (error) { + if (isUniqueViolation(error)) throw new NameTakenError(NAME_TAKEN) + throw error + } + }, + + renameRoutine: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const routine = await ownRoutine(context, coachId, args.id) + const name = cleanName(args.name, 'routine') + try { + const renamed = await context.prisma.routine.update({ + where: { id: routine.id }, + data: { name }, + }) + forgetGymnast(context, routine.gymnastId) + return renamed + } catch (error) { + if (isUniqueViolation(error)) throw new NameTakenError(NAME_TAKEN) + throw error + } + }, + + deleteRoutine: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const routine = await ownRoutine(context, coachId, args.id) + await context.prisma.routine.delete({ where: { id: routine.id } }) + forgetGymnast(context, routine.gymnastId) + context.loaders.itemsOfRoutine.clear(routine.id) + return routine.id + }, + + /** + * Replaces the whole list of the routine, in the order of `itemIds`. The + * last save wins. A removed item may stay in the routine but cannot be added. + */ + setRoutineItems: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const routine = await ownRoutine(context, coachId, args.routineId) + const { itemIds } = args + if (itemIds.length > MAX_ITEMS_PER_ROUTINE) { + throw new LimitReachedError( + `A routine can have at most ${MAX_ITEMS_PER_ROUTINE} items.`, + ) + } + if (itemIds.some((id) => !ITEM_ID_PATTERN.test(id))) { + throw new BadUserInputError('An item id is not valid.') + } + + // Constraints 7 and 8: the items are on the routine's apparatus, and + // there is one vault on vault (and no vault elsewhere, since a vault is + // always on VT). + const unique = [...new Set(itemIds)] + const loaded = await context.loaders.itemById.loadMany(unique) + const found = new Map() + loaded.forEach((item, index) => { + if (!item || item instanceof Error) { + throw new BadUserInputError( + `There is no item with id ${unique[index]}.`, + ) + } + found.set(item.id, item) + }) + for (const id of unique) { + if (apparatusOfId(id) !== routine.apparatusId) { + throw new InvalidItemsError( + `${id} is not on the apparatus of this routine (${routine.apparatusId}).`, + ) + } + } + if (routine.apparatusId === 'VT' && itemIds.length > 1) { + throw new InvalidItemsError('A routine on vault has only one vault.') + } + + // A removed item can stay if it is already in the routine, but not be added. + const current = new Set( + (await context.loaders.itemsOfRoutine.load(routine.id)).map( + (item) => item.id, + ), + ) + for (const item of found.values()) { + if (item.removedAt !== null && !current.has(item.id)) { + throw new InvalidItemsError( + `${item.id} is no longer in the Code of Points and cannot be added.`, + ) + } + } + + const updated = await context.prisma.$transaction(async (tx) => { + // Changing the routine first takes a lock on its row, so two saves at + // the same moment follow each other. It also sets updatedAt, which the + // items (separate rows) would not do. + const updated = await tx.routine.update({ + where: { id: routine.id }, + data: { updatedAt: context.now() }, + }) + await tx.routineElement.deleteMany({ where: { routineId: routine.id } }) + await tx.routineElement.createMany({ + data: itemIds.map((id, index) => ({ + routineId: routine.id, + position: index + 1, + ...(isVaultId(id) ? { vaultId: id } : { elementId: id }), + })), + }) + return updated + }) + context.loaders.itemsOfRoutine.clear(routine.id) + return updated + }, }, } satisfies Resolvers diff --git a/backend/src/server/server.test.ts b/backend/src/server/server.test.ts index 0773037..715f6e9 100644 --- a/backend/src/server/server.test.ts +++ b/backend/src/server/server.test.ts @@ -526,7 +526,7 @@ describe.runIf(hasDatabase)('the GraphQL server on the seeded catalog', () => { }) }) - describe('what is not built yet', () => { + describe('without a login', () => { it('answers null for me when nobody is logged in', async () => { const { data, errors } = await run<{ me: null }>('{ me { id } }') expect(errors).toBeUndefined() diff --git a/backend/src/server/userData.test.ts b/backend/src/server/userData.test.ts new file mode 100644 index 0000000..b18babc --- /dev/null +++ b/backend/src/server/userData.test.ts @@ -0,0 +1,1022 @@ +// Tests for the gymnasts, their lists and the routines (issue #73). They run +// against the local database from backend/.env and need the seeded catalog. +// They write two test coaches with their data, and delete them again (the +// database deletes the gymnasts, routines and lists with the coach). One +// element is marked as removed for the tests of removed items, and restored +// afterwards. Without DATABASE_URL they are skipped. + +import type { ApolloServer } from '@apollo/server' +import { PrismaPg } from '@prisma/adapter-pg' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { signAccessToken } from '../auth/accessToken.js' +import { createPrismaClient } from '../db.js' +import { calculateDScore, toScoredItem } from '../dscore.js' +import { PrismaClient as LoggingClient } from '../generated/prisma/client.js' +import type { PrismaClient } from '../generated/prisma/client.js' +import { createApolloServer } from './app.js' +import type { Config } from './config.js' +import { type Context, createContext } from './context.js' +import { asElement, asVault } from './items.js' + +const hasDatabase = Boolean(process.env['DATABASE_URL']) +const PREFIX = 'test-73-' +const START = new Date('2026-10-17T10:00:00Z') + +const config: Config = { + port: 3001, + imageBaseUrl: 'http://images.test/images', + corsOrigins: [], + sessionSecret: 'a-secret-that-is-at-least-32-characters-long', +} + +type GqlError = { message: string; extensions?: Record } +type Result = { + data: T | null | undefined + errors: readonly GqlError[] | undefined +} + +describe.runIf(hasDatabase)('gymnasts, lists and routines', () => { + let prisma: PrismaClient + let server: ApolloServer + let anna: { id: string; token: string } + let bo: { id: string; token: string } + /** Elements of the seeded catalog, one for each code, and the first vault. */ + let ub: string[] + let bb: string[] + let vaults: string[] + + const clean = () => + prisma.coach.deleteMany({ where: { googleId: { startsWith: PREFIX } } }) + + /** Runs a query as a coach (or as nobody) on the server without HTTP. */ + async function run>( + who: { token: string } | null, + query: string, + variables?: Record, + options: { now?: Date; client?: PrismaClient } = {}, + ): Promise> { + const response = await server.executeOperation( + { query, variables }, + { + contextValue: createContext(options.client ?? prisma, config, { + authorization: who ? `Bearer ${who.token}` : undefined, + now: () => options.now ?? START, + }), + }, + ) + if (response.body.kind !== 'single') throw new Error('expected one result') + const { data, errors } = response.body.singleResult + return { data: data as T | null | undefined, errors } + } + const codeOf = (r: Result) => r.errors?.[0]?.extensions?.['code'] + + /** Runs a query that must work, and returns its data. */ + async function ok( + who: { token: string }, + query: string, + variables?: Record, + options?: { now?: Date }, + ): Promise { + const result = await run(who, query, variables, options) + expect(result.errors).toBeUndefined() + return result.data! + } + + const createGymnast = async (who: { token: string }, name: string) => + ( + await ok<{ createGymnast: { id: string; name: string } }>( + who, + 'mutation ($n: String!) { createGymnast(name: $n) { id name } }', + { n: name }, + ) + ).createGymnast + + const createRoutine = async ( + who: { token: string }, + gymnastId: string, + apparatusId: string, + name: string, + ) => + ( + await ok<{ createRoutine: { id: string; name: string } }>( + who, + 'mutation ($i: CreateRoutineInput!) { createRoutine(input: $i) { id name } }', + { i: { gymnastId, apparatusId, name } }, + ) + ).createRoutine + + const SET_ITEMS = `mutation ($r: ID!, $ids: [ID!]!) { setRoutineItems(routineId: $r, itemIds: $ids) { id updatedAt dScore items { id removed } } }` + const ADD = `mutation ($g: ID!, $i: ID!) { addGymnastElement(gymnastId: $g, itemId: $i) { id elements { id } } }` + const REMOVE = `mutation ($g: ID!, $i: ID!) { removeGymnastElement(gymnastId: $g, itemId: $i) { id elements { id } } }` + const LIST = `query ($g: ID!, $a: ID) { gymnast(id: $g) { elements(apparatusId: $a) { id } } }` + + const listOf = async (who: { token: string }, gymnastId: string) => + ( + await ok<{ gymnast: { elements: { id: string }[] } }>(who, LIST, { + g: gymnastId, + }) + ).gymnast.elements.map((e) => e.id) + + const itemsOf = async (who: { token: string }, routineId: string) => + ( + await ok<{ routine: { items: { id: string }[] } }>( + who, + 'query ($r: ID!) { routine(id: $r) { items { id } } }', + { r: routineId }, + ) + ).routine.items.map((i) => i.id) + + /** The first element of each of the first `n` codes on an apparatus. */ + async function elementsOn(apparatus: string, n: number): Promise { + const rows = await prisma.element.findMany({ + where: { id: { startsWith: `${apparatus}-` }, removedAt: null }, + orderBy: { id: 'asc' }, + }) + const seen = new Set() + return rows + .filter((r) => !seen.has(r.code) && seen.add(r.code)) + .slice(0, n) + .map((r) => r.id) + } + + async function makeCoach(name: string) { + const coach = await prisma.coach.create({ + data: { + googleId: `${PREFIX}${name}`, + email: `${name}@example.com`, + name, + }, + }) + return { + id: coach.id, + token: await signAccessToken(coach.id, config.sessionSecret!, START), + } + } + + beforeAll(async () => { + prisma = createPrismaClient() + server = createApolloServer() + await server.start() + await clean() + anna = await makeCoach('anna') + bo = await makeCoach('bo') + ;[ub, bb] = await Promise.all([elementsOn('UB', 60), elementsOn('BB', 12)]) + vaults = ( + await prisma.vault.findMany({ orderBy: { id: 'asc' }, take: 5 }) + ).map((v) => v.id) + }) + + afterAll(async () => { + await clean() + await server.stop() + await prisma.$disconnect() + }) + + describe('login and ownership', () => { + const operations: [string, string, Record][] = [ + ['gymnast', '{ gymnast(id: "x") { id } }', {}], + ['routine', '{ routine(id: "x") { id } }', {}], + ['createGymnast', 'mutation { createGymnast(name: "A") { id } }', {}], + [ + 'renameGymnast', + 'mutation { renameGymnast(id: "x", name: "A") { id } }', + {}, + ], + ['deleteGymnast', 'mutation { deleteGymnast(id: "x") }', {}], + [ + 'addGymnastElement', + 'mutation { addGymnastElement(gymnastId: "x", itemId: "UB-1.101-a") { id } }', + {}, + ], + [ + 'removeGymnastElement', + 'mutation { removeGymnastElement(gymnastId: "x", itemId: "UB-1.101-a") { id } }', + {}, + ], + [ + 'createRoutine', + 'mutation { createRoutine(input: {gymnastId: "x", apparatusId: "UB", name: "A"}) { id } }', + {}, + ], + [ + 'renameRoutine', + 'mutation { renameRoutine(id: "x", name: "A") { id } }', + {}, + ], + ['deleteRoutine', 'mutation { deleteRoutine(id: "x") }', {}], + [ + 'setRoutineItems', + 'mutation { setRoutineItems(routineId: "x", itemIds: []) { id } }', + {}, + ], + ] + + it.each(operations)('%s needs a login', async (_name, query) => { + expect(codeOf(await run(null, query))).toBe('UNAUTHENTICATED') + expect(codeOf(await run({ token: 'not-a-token' }, query))).toBe( + 'UNAUTHENTICATED', + ) + expect( + codeOf( + await run({ token: anna.token }, query, undefined, { + now: new Date(START.getTime() + 20 * 60 * 1000), + }), + ), + ).toBe('UNAUTHENTICATED') // the access token has expired + }) + + it("never lets a coach reach another coach's data: NOT_FOUND, and nothing changes", async () => { + const gymnast = await createGymnast(anna, 'Annas gymnast') + const routine = await createRoutine( + anna, + gymnast.id, + 'UB', + 'Annas routine', + ) + await ok(anna, ADD, { g: gymnast.id, i: ub[0] }) + await ok(anna, SET_ITEMS, { r: routine.id, ids: [ub[0], ub[1]] }) + + const attacks: [string, string, Record][] = [ + ['gymnast', '{ gymnast(id: "G") { id } }', {}], + ['routine', '{ routine(id: "R") { id } }', {}], + [ + 'renameGymnast', + 'mutation { renameGymnast(id: "G", name: "Hacked") { id } }', + {}, + ], + ['deleteGymnast', 'mutation { deleteGymnast(id: "G") }', {}], + [ + 'addGymnastElement', + `mutation { addGymnastElement(gymnastId: "G", itemId: "${ub[2]}") { id } }`, + {}, + ], + [ + 'removeGymnastElement', + `mutation { removeGymnastElement(gymnastId: "G", itemId: "${ub[0]}") { id } }`, + {}, + ], + [ + 'createRoutine', + 'mutation { createRoutine(input: {gymnastId: "G", apparatusId: "UB", name: "Hacked"}) { id } }', + {}, + ], + [ + 'renameRoutine', + 'mutation { renameRoutine(id: "R", name: "Hacked") { id } }', + {}, + ], + ['deleteRoutine', 'mutation { deleteRoutine(id: "R") }', {}], + [ + 'setRoutineItems', + `mutation { setRoutineItems(routineId: "R", itemIds: ["${ub[5]}"]) { id } }`, + {}, + ], + ] + const messages = new Set() + for (const [name, template] of attacks) { + const query = template + .replace('"G"', `"${gymnast.id}"`) + .replace('"R"', `"${routine.id}"`) + const result = await run(bo, query) + expect(codeOf(result), name).toBe('NOT_FOUND') + messages.add( + `${name}:${result.errors![0]!.message.replace(/gymnast|routine/i, 'X')}`, + ) + } + + // The same answer as for an id that does not exist, so ids cannot be probed. + const unknown = await run(bo, '{ gymnast(id: "does-not-exist") { id } }') + const other = await run(bo, `{ gymnast(id: "${gymnast.id}") { id } }`) + expect(unknown.errors![0]!.message).toBe(other.errors![0]!.message) + + // Nothing was changed. + const back = await ok<{ + gymnast: { name: string; routines: { name: string }[] } + }>(anna, `{ gymnast(id: "${gymnast.id}") { name routines { name } } }`) + expect(back.gymnast.name).toBe('Annas gymnast') + expect(back.gymnast.routines.map((r) => r.name)).toEqual([ + 'Annas routine', + ]) + expect(await listOf(anna, gymnast.id)).toEqual([ub[0]]) + expect(await itemsOf(anna, routine.id)).toEqual([ub[0], ub[1]]) + }) + + it("does not show another coach's gymnasts in me", async () => { + await createGymnast(bo, 'Bos gymnast') + const mine = await ok<{ me: { gymnasts: { name: string }[] } }>( + bo, + '{ me { gymnasts { name } } }', + ) + expect(mine.me.gymnasts.map((g) => g.name)).toEqual(['Bos gymnast']) + }) + }) + + describe('gymnasts', () => { + it('creates, renames and deletes a gymnast', async () => { + const created = await createGymnast(anna, ' Kari Nilsen ') + expect(created.name).toBe('Kari Nilsen') // trimmed + const renamed = await ok<{ renameGymnast: { name: string } }>( + anna, + `mutation { renameGymnast(id: "${created.id}", name: " Kari N ") { name } }`, + ) + expect(renamed.renameGymnast.name).toBe('Kari N') + + const deleted = await ok<{ deleteGymnast: string }>( + anna, + `mutation { deleteGymnast(id: "${created.id}") }`, + ) + expect(deleted.deleteGymnast).toBe(created.id) + const gone = await run(anna, `{ gymnast(id: "${created.id}") { id } }`) + expect(codeOf(gone)).toBe('NOT_FOUND') + }) + + it('deletes the routines and the list with the gymnast', async () => { + const gymnast = await createGymnast(anna, 'To delete') + const routine = await createRoutine(anna, gymnast.id, 'BB', 'R1') + await ok(anna, ADD, { g: gymnast.id, i: bb[0] }) + await ok(anna, SET_ITEMS, { r: routine.id, ids: [bb[0]] }) + + await ok(anna, `mutation { deleteGymnast(id: "${gymnast.id}") }`) + expect( + await prisma.routine.count({ where: { gymnastId: gymnast.id } }), + ).toBe(0) + expect( + await prisma.routineElement.count({ where: { routineId: routine.id } }), + ).toBe(0) + expect( + await prisma.gymnastElement.count({ where: { gymnastId: gymnast.id } }), + ).toBe(0) + }) + + it('trims the name and allows 1-70 characters, counted as characters', async () => { + const mutation = + 'mutation ($n: String!) { createGymnast(name: $n) { name } }' + for (const bad of ['', ' ', 'x'.repeat(71), 'πŸ˜€'.repeat(71)]) { + expect( + codeOf(await run(anna, mutation, { n: bad })), + JSON.stringify(bad.slice(0, 5)), + ).toBe('BAD_USER_INPUT') + } + for (const good of [ + 'x', + 'x'.repeat(70), + 'πŸ˜€'.repeat(70), + 'æøΓ₯'.repeat(23), + ]) { + const result = await run<{ createGymnast: { name: string } }>( + anna, + mutation, + { n: good }, + ) + expect(result.errors, good.slice(0, 5)).toBeUndefined() + expect(result.data!.createGymnast.name).toBe(good) + } + // 70 characters plus spaces around them is fine, since the spaces are removed. + const padded = await run<{ createGymnast: { name: string } }>( + anna, + mutation, + { n: ` ${'y'.repeat(70)} ` }, + ) + expect(padded.data!.createGymnast.name).toBe('y'.repeat(70)) + }) + + it('stops at 200 gymnasts for a coach, and does not count other coaches', async () => { + const coach = await makeCoach('many') + await prisma.gymnast.createMany({ + data: Array.from({ length: 200 }, (_, i) => ({ + coachId: coach.id, + name: `G${i}`, + })), + }) + const mutation = 'mutation { createGymnast(name: "One more") { id } }' + expect(codeOf(await run(coach, mutation))).toBe('LIMIT_REACHED') + // Another coach is not affected. + expect((await run(bo, mutation)).errors).toBeUndefined() + // Deleting one makes room. + const first = await prisma.gymnast.findFirstOrThrow({ + where: { coachId: coach.id }, + }) + await ok(coach, `mutation { deleteGymnast(id: "${first.id}") }`) + expect((await run(coach, mutation)).errors).toBeUndefined() + }) + + it('lists the gymnasts of the coach by name', async () => { + const coach = await makeCoach('sorted') + for (const name of ['Cecilie', 'Anna', 'Berit']) + await createGymnast(coach, name) + const me = await ok<{ me: { gymnasts: { name: string }[] } }>( + coach, + '{ me { gymnasts { name } } }', + ) + expect(me.me.gymnasts.map((g) => g.name)).toEqual([ + 'Anna', + 'Berit', + 'Cecilie', + ]) + }) + }) + + describe("a gymnast's list", () => { + it('adds items in order, and can show only one apparatus', async () => { + const gymnast = await createGymnast(anna, 'List') + await ok(anna, ADD, { g: gymnast.id, i: ub[3] }) + await ok(anna, ADD, { g: gymnast.id, i: bb[1] }) + await ok(anna, ADD, { g: gymnast.id, i: vaults[0] }) + await ok(anna, ADD, { g: gymnast.id, i: ub[1] }) + + expect(await listOf(anna, gymnast.id)).toEqual([ + ub[3], + bb[1], + vaults[0], + ub[1], + ]) + const onUb = await ok<{ gymnast: { elements: { id: string }[] } }>( + anna, + LIST, + { g: gymnast.id, a: 'UB' }, + ) + expect(onUb.gymnast.elements.map((e) => e.id)).toEqual([ub[3], ub[1]]) + const onVault = await ok<{ gymnast: { elements: { id: string }[] } }>( + anna, + LIST, + { g: gymnast.id, a: 'VT' }, + ) + expect(onVault.gymnast.elements.map((e) => e.id)).toEqual([vaults[0]]) + }) + + it('does nothing, without an error, when an item is added twice or removed when it is not there', async () => { + const gymnast = await createGymnast(anna, 'No-op') + await ok(anna, ADD, { g: gymnast.id, i: ub[0] }) + + const again = await ok<{ + addGymnastElement: { elements: { id: string }[] } + }>(anna, ADD, { g: gymnast.id, i: ub[0] }) + expect(again.addGymnastElement.elements.map((e) => e.id)).toEqual([ub[0]]) + expect( + await prisma.gymnastElement.count({ where: { gymnastId: gymnast.id } }), + ).toBe(1) + + const notThere = await ok<{ + removeGymnastElement: { elements: { id: string }[] } + }>(anna, REMOVE, { g: gymnast.id, i: ub[9] }) + expect(notThere.removeGymnastElement.elements.map((e) => e.id)).toEqual([ + ub[0], + ]) + const unknown = await run(anna, REMOVE, { + g: gymnast.id, + i: 'UB-99.99-a', + }) + expect(unknown.errors).toBeUndefined() + + const removed = await ok<{ + removeGymnastElement: { elements: { id: string }[] } + }>(anna, REMOVE, { g: gymnast.id, i: ub[0] }) + expect(removed.removeGymnastElement.elements).toEqual([]) + }) + + it('gives the updated list when several changes are in one request', async () => { + const gymnast = await createGymnast(anna, 'Several') + const result = await ok<{ + a: { elements: { id: string }[] } + b: { elements: { id: string }[] } + c: { elements: { id: string }[] } + }>( + anna, + `mutation ($g: ID!, $x: ID!, $y: ID!) { + a: addGymnastElement(gymnastId: $g, itemId: $x) { elements { id } } + b: addGymnastElement(gymnastId: $g, itemId: $y) { elements { id } } + c: removeGymnastElement(gymnastId: $g, itemId: $x) { elements { id } } + }`, + { g: gymnast.id, x: ub[0], y: ub[1] }, + ) + expect(result.a.elements.map((e) => e.id)).toEqual([ub[0]]) + expect(result.b.elements.map((e) => e.id)).toEqual([ub[0], ub[1]]) + expect(result.c.elements.map((e) => e.id)).toEqual([ub[1]]) + }) + + it('answers NOT_FOUND for an item that does not exist', async () => { + const gymnast = await createGymnast(anna, 'Unknown item') + for (const itemId of ['UB-99.99-a', "x'; DROP TABLE elements; --", '']) { + expect( + codeOf(await run(anna, ADD, { g: gymnast.id, i: itemId })), + itemId, + ).toBe('NOT_FOUND') + } + }) + + it('stops at 100 items, but an item that is already there is still fine', async () => { + const gymnast = await createGymnast(anna, 'Full list') + const rows = ( + await prisma.element.findMany({ + where: { removedAt: null }, + orderBy: { id: 'asc' }, + take: 101, + }) + ).map((e) => e.id) + await prisma.gymnastElement.createMany({ + data: rows + .slice(0, 100) + .map((id) => ({ gymnastId: gymnast.id, elementId: id })), + }) + expect( + codeOf(await run(anna, ADD, { g: gymnast.id, i: rows[100] })), + ).toBe('LIMIT_REACHED') + expect( + (await run(anna, ADD, { g: gymnast.id, i: rows[0] })).errors, + ).toBeUndefined() + await ok(anna, REMOVE, { g: gymnast.id, i: rows[0] }) + expect( + (await run(anna, ADD, { g: gymnast.id, i: rows[100] })).errors, + ).toBeUndefined() + }) + + it('puts one item in the list once when two requests add it at the same moment', async () => { + const gymnast = await createGymnast(anna, 'Race') + const results = await Promise.all( + Array.from({ length: 4 }, () => + run(anna, ADD, { g: gymnast.id, i: ub[7] }), + ), + ) + for (const result of results) expect(result.errors).toBeUndefined() + expect( + await prisma.gymnastElement.count({ where: { gymnastId: gymnast.id } }), + ).toBe(1) + }) + }) + + describe('routines', () => { + it('creates, renames and deletes a routine', async () => { + const gymnast = await createGymnast(anna, 'Routines') + const routine = await createRoutine(anna, gymnast.id, 'FX', ' Floor 1 ') + expect(routine.name).toBe('Floor 1') + + const renamed = await ok<{ renameRoutine: { name: string } }>( + anna, + `mutation { renameRoutine(id: "${routine.id}", name: "Floor 2") { name } }`, + ) + expect(renamed.renameRoutine.name).toBe('Floor 2') + // Renaming to its own name is fine. + expect( + ( + await run( + anna, + `mutation { renameRoutine(id: "${routine.id}", name: "Floor 2") { id } }`, + ) + ).errors, + ).toBeUndefined() + + const deleted = await ok<{ deleteRoutine: string }>( + anna, + `mutation { deleteRoutine(id: "${routine.id}") }`, + ) + expect(deleted.deleteRoutine).toBe(routine.id) + expect( + codeOf(await run(anna, `{ routine(id: "${routine.id}") { id } }`)), + ).toBe('NOT_FOUND') + }) + + it('shows the fields of a routine: apparatus, gymnast and ISO dates', async () => { + const gymnast = await createGymnast(anna, 'Fields') + const routine = await createRoutine(anna, gymnast.id, 'BB', 'Beam') + const data = await ok<{ + routine: { + name: string + createdAt: string + updatedAt: string + apparatus: { id: string; name: string } + gymnast: { id: string } + items: unknown[] + dScore: number + } + }>( + anna, + `{ routine(id: "${routine.id}") { name createdAt updatedAt apparatus { id name } gymnast { id } items { id } dScore } }`, + ) + expect(data.routine.apparatus.id).toBe('BB') + expect(data.routine.gymnast.id).toBe(gymnast.id) + expect(data.routine.items).toEqual([]) + expect(data.routine.dScore).toBe(0) + expect(data.routine.createdAt).toMatch( + /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/, + ) + expect(data.routine.updatedAt).toMatch(/Z$/) + }) + + it('trims the name and allows 1-70 characters', async () => { + const gymnast = await createGymnast(anna, 'Names') + const mutation = + 'mutation ($i: CreateRoutineInput!) { createRoutine(input: $i) { name } }' + for (const bad of ['', ' ', 'x'.repeat(71)]) { + expect( + codeOf( + await run(anna, mutation, { + i: { gymnastId: gymnast.id, apparatusId: 'UB', name: bad }, + }), + ), + ).toBe('BAD_USER_INPUT') + } + const good = await run(anna, mutation, { + i: { gymnastId: gymnast.id, apparatusId: 'UB', name: 'z'.repeat(70) }, + }) + expect(good.errors).toBeUndefined() + }) + + it('refuses a name that exists for the gymnast and apparatus, but not on another apparatus or gymnast', async () => { + const first = await createGymnast(anna, 'Names 1') + const second = await createGymnast(anna, 'Names 2') + const routine = await createRoutine(anna, first.id, 'UB', 'Same') + + const dup = await run( + anna, + 'mutation ($i: CreateRoutineInput!) { createRoutine(input: $i) { id } }', + { i: { gymnastId: first.id, apparatusId: 'UB', name: ' Same ' } }, + ) + expect(codeOf(dup)).toBe('NAME_TAKEN') + await createRoutine(anna, first.id, 'BB', 'Same') // another apparatus + await createRoutine(anna, second.id, 'UB', 'Same') // another gymnast + + const other = await createRoutine(anna, first.id, 'UB', 'Other') + expect( + codeOf( + await run( + anna, + `mutation { renameRoutine(id: "${other.id}", name: "Same") { id } }`, + ), + ), + ).toBe('NAME_TAKEN') + expect(routine.name).toBe('Same') + }) + + it('refuses an apparatus that does not exist, and stops at 50 routines', async () => { + const gymnast = await createGymnast(anna, 'Many routines') + const mutation = + 'mutation ($i: CreateRoutineInput!) { createRoutine(input: $i) { id } }' + expect( + codeOf( + await run(anna, mutation, { + i: { gymnastId: gymnast.id, apparatusId: 'XX', name: 'A' }, + }), + ), + ).toBe('BAD_USER_INPUT') + + await prisma.routine.createMany({ + data: Array.from({ length: 50 }, (_, i) => ({ + gymnastId: gymnast.id, + apparatusId: 'FX', + name: `R${i}`, + })), + }) + expect( + codeOf( + await run(anna, mutation, { + i: { gymnastId: gymnast.id, apparatusId: 'UB', name: 'One more' }, + }), + ), + ).toBe('LIMIT_REACHED') + }) + + it('lists the routines of a gymnast by apparatus and name, or only one apparatus', async () => { + const gymnast = await createGymnast(anna, 'Order') + await createRoutine(anna, gymnast.id, 'FX', 'B') + await createRoutine(anna, gymnast.id, 'UB', 'Z') + await createRoutine(anna, gymnast.id, 'UB', 'A') + await createRoutine(anna, gymnast.id, 'VT', 'V') + + const all = await ok<{ + gymnast: { routines: { name: string; apparatus: { id: string } }[] } + }>( + anna, + `{ gymnast(id: "${gymnast.id}") { routines { name apparatus { id } } } }`, + ) + expect( + all.gymnast.routines.map((r) => `${r.apparatus.id}:${r.name}`), + ).toEqual(['VT:V', 'UB:A', 'UB:Z', 'FX:B']) + const onUb = await ok<{ gymnast: { routines: { name: string }[] } }>( + anna, + `{ gymnast(id: "${gymnast.id}") { routines(apparatusId: "UB") { name } } }`, + ) + expect(onUb.gymnast.routines.map((r) => r.name)).toEqual(['A', 'Z']) + }) + }) + + describe('the items of a routine', () => { + let gymnastId: string + let n = 0 + const newRoutine = async (apparatus: string) => { + n += 1 + return (await createRoutine(anna, gymnastId, apparatus, `Items ${n}`)).id + } + + beforeAll(async () => { + gymnastId = (await createGymnast(anna, 'Items')).id + }) + + it('replaces the whole list, in order, and the same element may be there twice', async () => { + const routine = await newRoutine('UB') + const first = await ok<{ setRoutineItems: { items: { id: string }[] } }>( + anna, + SET_ITEMS, + { r: routine, ids: [ub[2], ub[0], ub[2]] }, + ) + expect(first.setRoutineItems.items.map((i) => i.id)).toEqual([ + ub[2], + ub[0], + ub[2], + ]) + + await ok(anna, SET_ITEMS, { r: routine, ids: [ub[4], ub[3]] }) + expect(await itemsOf(anna, routine)).toEqual([ub[4], ub[3]]) + const rows = await prisma.routineElement.findMany({ + where: { routineId: routine }, + orderBy: { position: 'asc' }, + }) + expect(rows.map((r) => r.position)).toEqual([1, 2]) // 1, 2, 3 ... without gaps + + await ok(anna, SET_ITEMS, { r: routine, ids: [] }) + expect(await itemsOf(anna, routine)).toEqual([]) + }) + + it('sets updatedAt, and keeps createdAt', async () => { + const routine = await newRoutine('UB') + const before = await ok<{ + routine: { createdAt: string; updatedAt: string } + }>(anna, `{ routine(id: "${routine}") { createdAt updatedAt } }`) + const later = new Date(START.getTime() + 10 * 60 * 1000) + const result = await ok<{ setRoutineItems: { updatedAt: string } }>( + anna, + SET_ITEMS, + { r: routine, ids: [ub[0]] }, + { now: later }, + ) + expect(result.setRoutineItems.updatedAt).toBe(later.toISOString()) + const after = await ok<{ + routine: { createdAt: string; updatedAt: string } + }>(anna, `{ routine(id: "${routine}") { createdAt updatedAt } }`) + expect(after.routine.createdAt).toBe(before.routine.createdAt) + expect(after.routine.updatedAt).toBe(later.toISOString()) + }) + + it('calculates dScore with the same rule as the calculator', async () => { + const routine = await newRoutine('UB') + const ids = ub.slice(0, 12) + const result = await ok<{ setRoutineItems: { dScore: number } }>( + anna, + SET_ITEMS, + { r: routine, ids }, + ) + const rows = await prisma.element.findMany({ where: { id: { in: ids } } }) + const expected = calculateDScore( + rows.map((r) => toScoredItem(asElement(r))), + ) + expect(result.setRoutineItems.dScore).toBe(expected) + expect(expected).toBeGreaterThan(0) + + const calc = await ok<{ calculateDScore: number }>( + anna, + '{ calculateDScore(itemIds: $ids) }'.replace( + '$ids', + JSON.stringify(ids), + ), + ) + expect(calc.calculateDScore).toBe(expected) + }) + + it('has the D-value of the vault on vault', async () => { + const routine = await newRoutine('VT') + const result = await ok<{ + setRoutineItems: { dScore: number; items: { id: string }[] } + }>(anna, SET_ITEMS, { r: routine, ids: [vaults[1]] }) + const vault = await prisma.vault.findUniqueOrThrow({ + where: { id: vaults[1]! }, + }) + expect(result.setRoutineItems.dScore).toBe( + calculateDScore([toScoredItem(asVault(vault))]), + ) + }) + + it('refuses items that do not fit the routine with INVALID_ITEMS, and then changes nothing', async () => { + const routine = await newRoutine('UB') + const vaultRoutine = await newRoutine('VT') + await ok(anna, SET_ITEMS, { r: routine, ids: [ub[0], ub[1]] }) + + const bad: [string, string, string[]][] = [ + ['an element from another apparatus', routine, [ub[2]!, bb[0]!]], + ['a vault on bars', routine, [vaults[0]!]], + ['an element on vault', vaultRoutine, [ub[0]!]], + ['two vaults', vaultRoutine, [vaults[0]!, vaults[1]!]], + ['the same vault twice', vaultRoutine, [vaults[0]!, vaults[0]!]], + ] + for (const [label, id, ids] of bad) { + expect(codeOf(await run(anna, SET_ITEMS, { r: id, ids })), label).toBe( + 'INVALID_ITEMS', + ) + } + expect(await itemsOf(anna, routine)).toEqual([ub[0], ub[1]]) // unchanged + expect(await itemsOf(anna, vaultRoutine)).toEqual([]) + // One vault is fine. + expect( + (await run(anna, SET_ITEMS, { r: vaultRoutine, ids: [vaults[0]] })) + .errors, + ).toBeUndefined() + }) + + it('stops at 50 items, and refuses unknown or odd ids', async () => { + const routine = await newRoutine('UB') + const fifty = Array.from({ length: 50 }, (_, i) => ub[i % ub.length]!) + expect( + (await run(anna, SET_ITEMS, { r: routine, ids: fifty })).errors, + ).toBeUndefined() + expect( + codeOf( + await run(anna, SET_ITEMS, { r: routine, ids: [...fifty, ub[0]] }), + ), + ).toBe('LIMIT_REACHED') + expect( + codeOf(await run(anna, SET_ITEMS, { r: routine, ids: ['UB-99.99-a'] })), + ).toBe('BAD_USER_INPUT') + expect( + codeOf( + await run(anna, SET_ITEMS, { + r: routine, + ids: ["x'; DROP TABLE routines; --"], + }), + ), + ).toBe('BAD_USER_INPUT') + expect((await itemsOf(anna, routine)).length).toBe(50) // unchanged + }) + + it('lets two saves at the same moment follow each other: the last one wins', async () => { + const routine = await newRoutine('UB') + const one = [ub[0]!, ub[1]!, ub[2]!] + const two = [ub[5]!, ub[4]!] + const results = await Promise.all([ + run(anna, SET_ITEMS, { r: routine, ids: one }), + run(anna, SET_ITEMS, { r: routine, ids: two }), + ]) + for (const result of results) expect(result.errors).toBeUndefined() + const final = await itemsOf(anna, routine) + expect([one, two]).toContainEqual(final) + }) + }) + + describe('removed items', () => { + let removedId: string + let gymnastId: string + + beforeAll(async () => { + removedId = ub[20]! + gymnastId = (await createGymnast(anna, 'Removed')).id + }) + + const setRemoved = (value: Date | null) => + prisma.element.update({ + where: { id: removedId }, + data: { removedAt: value }, + }) + + afterAll(async () => { + await setRemoved(null) + }) + + it('can stay in a routine and a list, shows removed: true, and makes dScore null', async () => { + const routine = (await createRoutine(anna, gymnastId, 'UB', 'Removed 1')) + .id + await ok(anna, SET_ITEMS, { r: routine, ids: [ub[0], removedId, ub[1]] }) + await ok(anna, ADD, { g: gymnastId, i: removedId }) + const before = await ok<{ routine: { dScore: number | null } }>( + anna, + `{ routine(id: "${routine}") { dScore } }`, + ) + expect(before.routine.dScore).toBeGreaterThan(0) + + await setRemoved(new Date()) + try { + const shown = await ok<{ + routine: { + dScore: number | null + items: { id: string; removed: boolean }[] + } + }>( + anna, + `{ routine(id: "${routine}") { dScore items { id removed } } }`, + ) + expect(shown.routine.items.map((i) => [i.id, i.removed])).toEqual([ + [ub[0], false], + [removedId, true], + [ub[1], false], + ]) + expect(shown.routine.dScore).toBeNull() + expect(await listOf(anna, gymnastId)).toContain(removedId) + + // It may stay when the routine is saved again, but not be added to another routine. + expect( + (await run(anna, SET_ITEMS, { r: routine, ids: [removedId, ub[1]] })) + .errors, + ).toBeUndefined() + const other = (await createRoutine(anna, gymnastId, 'UB', 'Removed 2')) + .id + expect( + codeOf(await run(anna, SET_ITEMS, { r: other, ids: [removedId] })), + ).toBe('INVALID_ITEMS') + + // Taking it out gives the score back. + const fixed = await ok<{ setRoutineItems: { dScore: number | null } }>( + anna, + SET_ITEMS, + { r: routine, ids: [ub[1]] }, + ) + expect(fixed.setRoutineItems.dScore).toBeGreaterThan(0) + } finally { + await setRemoved(null) + } + }) + + it('cannot be added to a list, but can be taken out of it', async () => { + const gymnast = (await createGymnast(anna, 'Removed list')).id + await setRemoved(new Date()) + try { + expect(codeOf(await run(anna, ADD, { g: gymnast, i: removedId }))).toBe( + 'INVALID_ITEMS', + ) + // Already in the list (added before it was removed): adding again is a no-op, and it can be removed. + await prisma.gymnastElement.create({ + data: { gymnastId: gymnast, elementId: removedId }, + }) + expect( + (await run(anna, ADD, { g: gymnast, i: removedId })).errors, + ).toBeUndefined() + await ok(anna, REMOVE, { g: gymnast, i: removedId }) + expect(await listOf(anna, gymnast)).toEqual([]) + } finally { + await setRemoved(null) + } + }) + }) + + describe('queries', () => { + it('uses a fixed number of queries, whatever the number of gymnasts', async () => { + /** A coach with `n` gymnasts, each with a list, two routines and items in them. */ + async function coachWith(name: string, n: number) { + const coach = await makeCoach(name) + for (let i = 0; i < n; i++) { + const g = await prisma.gymnast.create({ + data: { coachId: coach.id, name: `Q${i}` }, + }) + await prisma.gymnastElement.createMany({ + data: [ + { gymnastId: g.id, elementId: ub[0]! }, + { gymnastId: g.id, elementId: ub[1]! }, + ], + }) + const r1 = await prisma.routine.create({ + data: { gymnastId: g.id, apparatusId: 'UB', name: 'One' }, + }) + const r2 = await prisma.routine.create({ + data: { gymnastId: g.id, apparatusId: 'BB', name: 'Two' }, + }) + await prisma.routineElement.createMany({ + data: [ + { routineId: r1.id, position: 1, elementId: ub[0]! }, + { routineId: r2.id, position: 1, elementId: bb[0]! }, + ], + }) + } + return coach + } + + /** The number of queries the server sends for the whole overview. */ + async function queriesFor(coach: { token: string }, gymnasts: number) { + const queries: string[] = [] + const logging = new LoggingClient({ + adapter: new PrismaPg({ + connectionString: process.env['DATABASE_URL']!, + }), + log: [{ emit: 'event', level: 'query' }], + }) + logging.$on('query', (e) => queries.push(e.query)) + try { + const result = await run<{ + me: { gymnasts: { routines: unknown[]; elements: unknown[] }[] } + }>( + coach, + '{ me { gymnasts { name routines { name dScore apparatus { id } items { id } } elements { id } } } }', + undefined, + { client: logging as unknown as PrismaClient }, + ) + expect(result.errors).toBeUndefined() + expect(result.data!.me.gymnasts).toHaveLength(gymnasts) + expect(result.data!.me.gymnasts[0]!.routines).toHaveLength(2) + expect(result.data!.me.gymnasts[0]!.elements).toHaveLength(2) + } finally { + await logging.$disconnect() + } + return queries.length + } + + const few = await queriesFor(await coachWith('queries-few', 2), 2) + const many = await queriesFor(await coachWith('queries-many', 25), 25) + expect(many).toBe(few) // the same for 2 and for 25 gymnasts + expect(many).toBeLessThanOrEqual(12) + }) + }) +}) diff --git a/backend/src/userData/rules.ts b/backend/src/userData/rules.ts new file mode 100644 index 0000000..b147066 --- /dev/null +++ b/backend/src/userData/rules.ts @@ -0,0 +1,28 @@ +// The limits and the rules for names in the user data (see "Limits" and +// "Names" in docs/API.md). The database does not check them (only the value +// ranges are checked there), so the backend does. + +import { BadUserInputError } from '../errors.js' + +export const MAX_GYMNASTS_PER_COACH = 200 +export const MAX_ROUTINES_PER_GYMNAST = 50 +export const MAX_ITEMS_PER_ROUTINE = 50 +export const MAX_ELEMENTS_PER_GYMNAST = 100 +/** Characters in the name of a gymnast or a routine. */ +export const MAX_NAME_LENGTH = 70 + +/** + * The name without spaces at the start and the end. It must be 1-70 + * characters. The length is counted in characters (code points), not in bytes + * or UTF-16 units, so a name with accents or emoji gets the same room as any other. + */ +export function cleanName(name: string, what: string): string { + const trimmed = name.trim() + const length = [...trimmed].length + if (length < 1 || length > MAX_NAME_LENGTH) { + throw new BadUserInputError( + `The name of a ${what} must be 1-${MAX_NAME_LENGTH} characters.`, + ) + } + return trimmed +} diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 59dc9ab..06be679 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -308,6 +308,21 @@ Claude's own was wrong (it used an old token and expected the new one to keep working), which showed the reuse check doing its job; the test was fixed. +## Gymnasts, lists and routines (`backend/src/server/resolvers/userData.ts`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #73 and decided the rules earlier: a coach only + reaches their own data (`NOT_FOUND` for others, the same as for an unknown id), + names of 1-70 characters, the limits of 200, 50, 50 and 100, no error when an item + is added to a list twice or removed when it is not there, a removed item stays but + cannot be added, and `Routine.dScore` is `null` with a removed item. +- **What Claude did:** Wrote the resolvers, the loaders (so the number of queries does + not grow with the number of gymnasts), the error classes, the `DateTime` scalar, and + 40 tests with two test coaches. Decided to lock the routine row at the start of + `setRoutineItems`, so two saves at the same moment follow each other, and tested it. + A first version of the test for updatedAt used an access token that had expired, + which showed that the tokens are checked against the time set in the test. + ## Database Infrastructure & Setup Completion (`backend/package.json`, `backend/.env.example`, `docs/VM_SETUP.md`, `docs/AI_USAGE.md`, `src/App.tsx`) diff --git a/docs/API.md b/docs/API.md index ba02440..1fef6d1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -235,16 +235,16 @@ The catalog, `searchCatalog` and `calculateDScore` do not need a login. Everythi Errors are thrown from the resolvers as GraphQL errors with a machine-readable code in `extensions.code`. The client reacts to the code and never to the text of the message. -| Code | When | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `UNAUTHENTICATED` | The operation needs a logged-in coach, or the session token or Google token is missing, invalid, or expired. The client sends the coach to the login page. | -| `NOT_FOUND` | The id does not exist, or belongs to another coach. | -| `NAME_TAKEN` | The gymnast already has a routine with this name on this apparatus (constraint 6). | -| `INVALID_ITEMS` | The items do not fit the routine: a vault outside vault, an element on vault, more than one vault, or an item from another apparatus (constraints 7 and 8). Also applies to a removed item unless it is already in the routine or gymnast's list. | +| Code | When | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `UNAUTHENTICATED` | The operation needs a logged-in coach, or the session token or Google token is missing, invalid, or expired. The client sends the coach to the login page. | +| `NOT_FOUND` | The id does not exist, or belongs to another coach. | +| `NAME_TAKEN` | The gymnast already has a routine with this name on this apparatus (constraint 6). | +| `INVALID_ITEMS` | The items do not fit the routine: a vault outside vault, an element on vault, more than one vault, or an item from another apparatus (constraints 7 and 8). Also applies to a removed item unless it is already in the routine or gymnast's list. | | `BAD_USER_INPUT` | Empty or overlong name, malformed id, `first` out of range, search text over 80 characters or 8 words, unknown apparatus/group, a group from another apparatus, invalid cursor or sort, difficulty sort without an apparatus, difficulty filter with vault, or difficulty value outside 0.1–1.0 steps. | -| `LIMIT_REACHED` | The coach has reached a limit: 200 gymnasts, 50 routines per gymnast, 50 items in a routine, or 100 items in a gymnast's list. See [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). | -| `GRAPHQL_VALIDATION_FAILED` | Added by Apollo Server when the query is invalid or exceeds the depth limit. | -| `INTERNAL_SERVER_ERROR` | Added by Apollo Server for an unexpected error. The client shows a general message without details. | +| `LIMIT_REACHED` | The coach has reached a limit: 200 gymnasts, 50 routines per gymnast, 50 items in a routine, or 100 items in a gymnast's list. See [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). | +| `GRAPHQL_VALIDATION_FAILED` | Added by Apollo Server when the query is invalid or exceeds the depth limit. | +| `INTERNAL_SERVER_ERROR` | Added by Apollo Server for an unexpected error. The client shows a general message without details. | An error response looks like this: @@ -271,23 +271,23 @@ The price of this choice is that the types do not force the client to handle the ## The server -The server is built (issue #28) in `backend/src/server/`. It answers the catalog queries and the search. The login is built (issue #70). The resolvers for gymnasts and routines are not. +The server is built (issue #28) in `backend/src/server/`. It answers the catalog queries and the search. The login is built (issue #70), and so are the gymnasts, their lists and the routines (issue #73). **Run it:** `npm run backend` (restarts on changes) or `npm run start -w backend`, after the database is migrated and seeded. The settings are in `backend/.env` (see Configuration). The code is split so each part can be read and tested on its own: -| File | What it does | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `index.ts` | Starts the server and shuts it down cleanly on `SIGINT` and `SIGTERM` | -| `server/app.ts` | Builds the Express app and the Apollo server: `/graphql`, `/images`, `/health` | -| `server/config.ts` | Reads and checks the settings; a wrong value stops the start with a clear message | -| `server/resolvers/` | `catalog.ts`, `calculator.ts`, `auth.ts` (`me`, `refreshSession`, `logout`, `login`) and `userData.ts` (errors for the gymnasts and routines, which are not built) | -| `server/loaders.ts` | The DataLoaders, one set for each request | -| `server/depthLimit.ts` | The depth limit as a validation rule | -| `server/formatError.ts` | How errors look to the client | -| `server/selectsField.ts` | Whether the query asks for a field, so `totalCount` is only calculated when it is asked for | -| `server/context.ts` | What every resolver can use: the database, the settings, the loaders and the logged-in coach | -| `auth/accessToken.ts` | Signs and checks the access token (`jose`) | -| `auth/sessions.ts` | The refresh tokens: start, rotate, end, delete the expired | +| File | What it does | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | +| `index.ts` | Starts the server and shuts it down cleanly on `SIGINT` and `SIGTERM` | +| `server/app.ts` | Builds the Express app and the Apollo server: `/graphql`, `/images`, `/health` | +| `server/config.ts` | Reads and checks the settings; a wrong value stops the start with a clear message | +| `server/resolvers/` | `catalog.ts`, `calculator.ts`, `auth.ts` (login, `me`) and `userData.ts` (gymnasts, lists, routines) | +| `server/loaders.ts` | The DataLoaders, one set for each request | +| `server/depthLimit.ts` | The depth limit as a validation rule | +| `server/formatError.ts` | How errors look to the client | +| `server/selectsField.ts` | Whether the query asks for a field, so `totalCount` is only calculated when it is asked for | +| `server/context.ts` | What every resolver can use: the database, the settings, the loaders and the logged-in coach | +| `auth/accessToken.ts` | Signs and checks the access token (`jose`) | +| `auth/sessions.ts` | The refresh tokens: start, rotate, end, delete the expired | The scripts are in `backend/package.json`: `dev` and `start` run `src/index.ts` with `tsx`. The server runs from the source, because `tsconfig.json` has `noEmit` and there is no `dist/` to run. `setup` runs the migrations and then the seed (`prisma migrate deploy` and `prisma db seed`), which is what a new database needs, such as the one on the virtual machine (issue #26). `build` only checks the types. @@ -311,16 +311,17 @@ The scripts are in `backend/package.json`: `dev` and `start` run `src/index.ts` - **`graphql` is pinned to version 16.** Apollo Server 5 needs `^16`, and the latest release is 17. - **`tsx` and `dotenv` are regular dependencies**, because the server runs with `tsx` and loads `backend/.env`. Install with `npm ci`, not with `--omit=dev`. -### What is not built yet +### The user data -The login is built. The resolvers for gymnasts and routines are not, and until they exist every such operation answers a clear error, so the schema can be used as it is and nothing returns a confusing `null`: +The resolvers for gymnasts, their lists and routines are in `server/resolvers/userData.ts` (issue #73). The rules are the ones in "User data" above and in the table of error codes: -| Operation | Answer now | -| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `gymnast`, `routine`, `Coach.gymnasts` and every mutation that needs a logged-in coach | `UNAUTHENTICATED` without a valid access token, `NOT_IMPLEMENTED` with one | -| `login` | `NOT_IMPLEMENTED` until the Google login is merged (issue #70, part 2) | - -`NOT_IMPLEMENTED` is temporary and is not in the table of error codes. It goes away when the login and the user data are built. A test checks that every mutation in the schema has a resolver, so a new mutation cannot be forgotten. +- **Ownership.** Every operation needs a login (`UNAUTHENTICATED`) and only reaches the logged-in coach's own data. A gymnast or routine of another coach gives `NOT_FOUND`, with the same message as an id that does not exist. The routine is checked through its gymnast. +- **Names** are trimmed and 1-70 characters, counted as characters (an emoji is one). **Limits:** 200 gymnasts per coach, 50 routines per gymnast, 50 items in a routine (`LIMIT_REACHED`), 100 items in a gymnast's list. A routine name is unique per gymnast and apparatus (`NAME_TAKEN`). The limits are checked before the row is written, so two requests at the same moment can pass the check together and go over a limit by the number of requests. The unique name is guarded by the database. +- **`setRoutineItems`** replaces the whole list in one transaction and sets `updatedAt`. It checks that every item is on the routine's apparatus, that there is at most one vault on vault, and that a removed item is only kept if it was already in the routine (`INVALID_ITEMS`); an unknown id is `BAD_USER_INPUT`. The first thing the transaction does is to update the routine row, which locks it, so two saves at the same moment follow each other and the last one wins. A save that fails changes nothing. +- **The gymnast's list:** adding an item that is already there, or removing one that is not, changes nothing and gives no error. A removed item cannot be added (`INVALID_ITEMS`) but can be taken out. An unknown item is `NOT_FOUND`. +- **`Routine.dScore`** is `calculateDScore` of the items, and `null` while the routine has a removed item. +- **No N+1 queries.** `Gymnast.routines`, `Gymnast.elements`, `Routine.items` and the gymnast and apparatus of a routine are DataLoaders, so a coach with 25 gymnasts costs the same number of queries as one with 2 (a test checks it). A mutation makes the loaders forget what it changed, so several changes in one request show the right list. +- `DateTime` is an ISO 8601 string in the answer. ## Tools @@ -442,6 +443,7 @@ Vitest runs the API tests against a test database, with queries sent to the serv - **Errors:** each code in the table is returned for the right case. A coach that asks for another coach's gymnast gets `NOT_FOUND`, and a request without a login gets `UNAUTHENTICATED` (but `me` gives `null`). - **The calculator** (`backend/src/dscore.test.ts`, `backend/src/server/calculator.test.ts`): fewer than, exactly and more than eight elements, the same code twice, a vault, an empty list, sums that would give `0.30000000000000004` as floats; the query needs no login, gives the same score as the function for catalog items, refuses items from two apparatus, a vault with elements, two vaults and a removed item with `INVALID_ITEMS`, refuses 51 ids, an unknown id and an id with SQL in it with `BAD_USER_INPUT`, and uses a fixed number of queries. - **The session** (`backend/src/server/auth.test.ts`, `backend/src/auth/`; the time is set by the tests): `me` returns the coach for a valid access token and `null` without a token, with a changed token, with a token signed with another key and after 15 minutes; a protected operation without a valid token gives `UNAUTHENTICATED`; a refresh token works once and gives new tokens; using an old one again ends the whole login; an expired, unknown or logged-out refresh token gives `UNAUTHENTICATED`; two requests with the same refresh token let only one through; the database holds a hash and not the token; the `Authorization` header over HTTP. +- **Gymnasts, lists and routines** (`backend/src/server/userData.test.ts`, with two test coaches): every operation needs a login; a coach never reaches another coach's gymnast, list or routine (`NOT_FOUND`, nothing changes, same message as an unknown id); names (trimmed, 1-70 characters, emoji count as one); 200 gymnasts, 100 list items, 50 routines and 50 items in a routine; `NAME_TAKEN`; a list in order and filtered by apparatus; adding twice and removing a missing item are no-ops, also several changes in one request and four requests at once; `setRoutineItems` order, positions without gaps, duplicates, `updatedAt`, wrong apparatus, two vaults, unknown and odd ids, a failed save changes nothing, two saves at once; removed items (stay, `removed: true`, `dScore` null, cannot be added); `dScore` equals the calculator; the number of queries is the same for 2 and 25 gymnasts. - **Wildcards and case:** a search for `%`, `_` or a word containing them does not match everything, and an uppercase search such as "SAL" finds the same items as "sal". - **Search text limits:** 81 characters or 9 words give `BAD_USER_INPUT`, and a text with no searchable words gives no results, while an empty text or only spaces gives all items. - **Deep pages:** the queries after a cursor contain no `OR` and no `OFFSET` above 0, so each one starts in the index right after the cursor.