From 6392b73d7c4f0f725a40d84957bf19680015e096 Mon Sep 17 00:00:00 2001 From: Ronja Date: Sat, 17 Oct 2026 10:26:18 +0200 Subject: [PATCH] feat: add the resolvers for gymnasts, their lists and routines A logged-in coach can create, rename and delete gymnasts and routines, change a gymnast's list one item at a time, and replace the items of a routine. Every operation only reaches the coach's own data (NOT_FOUND for anything else). Names are 1-70 characters, the lists have limits, NAME_TAKEN, INVALID_ITEMS and LIMIT_REACHED are returned in the right cases, and Routine.dScore is null while the routine has a removed item. The loaders keep the number of queries the same for any number of gymnasts. --- README.md | 4 +- backend/src/dscore.ts | 8 +- backend/src/errors.ts | 41 +- backend/src/server/auth.test.ts | 2 +- backend/src/server/items.ts | 3 + backend/src/server/loaders.ts | 71 +- backend/src/server/resolvers/calculator.ts | 6 +- backend/src/server/resolvers/index.ts | 16 + backend/src/server/resolvers/userData.ts | 371 ++++++- backend/src/server/server.test.ts | 2 +- backend/src/server/userData.test.ts | 1022 ++++++++++++++++++++ backend/src/userData/rules.ts | 28 + docs/AI_USAGE.md | 15 + docs/API.md | 64 +- 14 files changed, 1581 insertions(+), 72 deletions(-) create mode 100644 backend/src/server/userData.test.ts create mode 100644 backend/src/userData/rules.ts diff --git a/README.md b/README.md index f1c9617..51c5d85 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 90e6149..a90615b 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 1967669..cade82c 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 bb5955d..5ebe02a 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -270,3 +270,18 @@ Google login. Ran the tests against a throwaway PostgreSQL database. One test of 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. diff --git a/docs/API.md b/docs/API.md index 83d7db2..6cf2a46 100644 --- a/docs/API.md +++ b/docs/API.md @@ -234,16 +234,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: @@ -270,23 +270,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. @@ -310,16 +310,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 @@ -441,6 +442,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.