diff --git a/project2/server/index.ts b/project2/server/index.ts index 76a2fed..11edecd 100644 --- a/project2/server/index.ts +++ b/project2/server/index.ts @@ -1,12 +1,12 @@ import { ApolloServer } from '@apollo/server'; import { startStandaloneServer } from '@apollo/server/standalone'; -import type { Pokemon, Ability, PokemonType } from '../src/types/pokemon'; +import type { Pokemon } from '../src/types/pokemon'; import db, { seedDatabase } from './db'; import pokemonsData from './pokemons.json' with { type: 'json' }; import { fetchPokemonFromPokeAPI } from './fetchFromPokeAPI'; import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled'; +import { resolvers, typeDefs } from './schema'; -// Seed database if empty const pokemonCount = ( db.prepare('SELECT COUNT(*) AS n FROM pokemons').get() as { n: number } ).n; @@ -31,381 +31,6 @@ if (pokemonCount === 0) { } } -function encodeCursor(value: any, id: number): string { - return Buffer.from(JSON.stringify({ v: value, i: id })).toString('base64'); -} - -function decodeCursor(cursor: string): { v: any; i: number } { - return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')); -} - -const typeDefs = `#graphql - type Ability { - name: String! - description: String! - short_description: String! - } - - type PokemonType { - name: String! - } - - type Pokemon { - id: Int! - name: String! - abilities: [Ability!]! - types: [PokemonType!]! - attack: Int! - defence: Int! - special_attack: Int! - special_defence: Int! - speed: Int! - hp: Int! - imageUrl: String! - averageRating: Float - ratingCount: Int! - } - - type PokemonPage { - items: [Pokemon!]! - total: Int! - hasNextPage: Boolean! - hasPreviousPage: Boolean! - endCursor: String - } - - enum PokemonSortField { - ID - NAME - HP - ATTACK - DEFENCE - SPECIAL_ATTACK - SPECIAL_DEFENCE - SPEED - } - - enum SortOrder { - ASC - DESC - } - - type Query { - pokemons( - search: String, - first: Int = 10, - after: String, - filter: String, - voterId: ID, - orderBy: String, - orderDir: String - ): PokemonPage! - pokemon(id: Int!): Pokemon - } - - type Mutation { - ratePokemon(pokemonId: Int!, voterId: ID!, value: Int!): Pokemon! - } -`; - -// Whitelist: column names cannot be SQL parameters, so map enum -> column -const SORT_COLUMNS = { - ID: 'id', - NAME: 'name COLLATE NOCASE', - HP: 'hp', - ATTACK: 'attack', - DEFENCE: 'defence', - SPECIAL_ATTACK: 'special_attack', - SPECIAL_DEFENCE: 'special_defence', - SPEED: 'speed', -} as const; - -/** - * Aggregated community rating for one Pokémon. - * AVG returns NULL when there are no ratings, which becomes a null - * `averageRating` in GraphQL. COUNT returns 0 in that case. - */ -interface RatingStats { - average: number | null; - votes: number; -} - -function getRatingStats(pokemonId: number): RatingStats { - return db - .prepare( - 'SELECT AVG(rating) AS average, COUNT(rating) AS votes FROM ratings WHERE pokemon_id = ?' - ) - .get(pokemonId) as RatingStats; -} - -const resolvers = { - Mutation: { - ratePokemon: ( - _parent: unknown, - args: { pokemonId: number; voterId: string; value: number } - ) => { - if (!Number.isInteger(args.pokemonId) || args.pokemonId < 1) { - throw new Error('Pokemon ID must be a positive integer.'); - } - if (!args.voterId.trim() || args.voterId.length > 128) { - throw new Error('Voter ID must be between 1 and 128 characters.'); - } - if ( - !Number.isInteger(args.value) || - args.value < 1 || - args.value > 5 - ) { - throw new Error('Rating must be an integer between 1 and 5.'); - } - const pokemon = db - .prepare('SELECT * FROM pokemons WHERE id = ?') - .get(args.pokemonId) as Pokemon | undefined; - if (!pokemon) { - throw new Error('Pokemon not found.'); - } - - db.prepare(` - INSERT INTO ratings (pokemon_id, voter_id, rating) - VALUES (?, ?, ?) - ON CONFLICT (pokemon_id, voter_id) - DO UPDATE SET rating = excluded.rating - `).run(args.pokemonId, args.voterId, args.value); - - // Return the Pokémon so clients can update their cache: the field - // resolvers below recompute averageRating and ratingCount. - return pokemon; - }, - }, - Query: { - pokemons: ( - _parent: unknown, - args: { - search?: string; - first?: number; - after?: string; - filter?: string; - voterId?: string; - orderBy?: string; - orderDir?: string; - } - ) => { - const first = Math.min(100, Math.max(1, args.first ?? 10)); - const search = args.search?.trim() ?? ''; - const filter = args.filter?.trim() ?? ''; - const orderBy = args.orderBy?.trim() ?? ''; - const orderDir = - args.orderDir?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; - const pattern = `%${search}%`; - const voterId = args.voterId ?? ''; - const allowedColumns = [ - 'id', - 'name', - 'attack', - 'defence', - 'special_attack', - 'special_defence', - 'speed', - 'hp', - ]; - const orderColumn = allowedColumns.includes(orderBy) - ? orderBy - : 'id'; - const ratingSort = orderBy === 'rating'; - const pokemonSource = ratingSort - ? 'pokemons p LEFT JOIN ratings r ON r.pokemon_id = p.id AND r.voter_id = ?' - : 'pokemons p'; - const sortExpression = ratingSort - ? 'COALESCE(r.rating, 0)' - : `p.${orderColumn}`; - const ratingSortColumn = ratingSort - ? ', COALESCE(r.rating, 0) AS rating_sort' - : ''; - - let after: { v: any; i: number } | null = null; - if (args.after) { - try { - after = decodeCursor(args.after); - } catch { - // ignore invalid cursor - } - } - - const op = orderDir === 'ASC' ? '>' : '<'; - const invOp = orderDir === 'ASC' ? '<' : '>'; - const limit = first + 1; - - let total: number; - let items: Pokemon[]; - let hasPreviousPage = false; - - if (filter) { - total = ( - db - .prepare( - 'SELECT COUNT(DISTINCT p.id) AS n FROM pokemons p JOIN types t ON p.id = t.pokemon_id WHERE p.name LIKE ? AND t.name = ?' - ) - .get(pattern, filter) as { n: number } - ).n; - - if (after) { - hasPreviousPage = - ( - db - .prepare( - `SELECT COUNT(DISTINCT p.id) AS n FROM ${pokemonSource} JOIN types t ON p.id = t.pokemon_id WHERE p.name LIKE ? AND t.name = ? AND (${sortExpression}, p.id) ${invOp} (?, ?)` - ) - .get( - ...(ratingSort ? [voterId] : []), - pattern, - filter, - after.v, - after.i - ) as { - n: number; - } - ).n > 0; - - items = db - .prepare( - `SELECT DISTINCT p.id, p.name, p.attack, p.defence, p.special_attack, p.special_defence, p.speed, p.hp${ratingSortColumn} - FROM ${pokemonSource} - JOIN types t ON p.id = t.pokemon_id - WHERE p.name LIKE ? AND t.name = ? AND (${sortExpression}, p.id) ${op} (?, ?) - ORDER BY ${sortExpression} ${orderDir}, p.id ${orderDir} - LIMIT ?` - ) - .all( - ...(ratingSort ? [voterId] : []), - pattern, - filter, - after.v, - after.i, - limit - ) as Pokemon[]; - } else { - items = db - .prepare( - `SELECT DISTINCT p.id, p.name, p.attack, p.defence, p.special_attack, p.special_defence, p.speed, p.hp${ratingSortColumn} - FROM ${pokemonSource} - JOIN types t ON p.id = t.pokemon_id - WHERE p.name LIKE ? AND t.name = ? - ORDER BY ${sortExpression} ${orderDir}, p.id ${orderDir} - LIMIT ?` - ) - .all( - ...(ratingSort ? [voterId] : []), - pattern, - filter, - limit - ) as Pokemon[]; - } - } else { - total = ( - db - .prepare( - 'SELECT COUNT(*) AS n FROM pokemons WHERE name LIKE ?' - ) - .get(pattern) as { n: number } - ).n; - - if (after) { - hasPreviousPage = - ( - db - .prepare( - `SELECT COUNT(*) AS n FROM ${pokemonSource} WHERE p.name LIKE ? AND (${sortExpression}, p.id) ${invOp} (?, ?)` - ) - .get( - ...(ratingSort ? [voterId] : []), - pattern, - after.v, - after.i - ) as { n: number } - ).n > 0; - - items = db - .prepare( - `SELECT p.id, p.name, p.attack, p.defence, p.special_attack, p.special_defence, p.speed, p.hp${ratingSortColumn} - FROM ${pokemonSource} - WHERE p.name LIKE ? AND (${sortExpression}, p.id) ${op} (?, ?) - ORDER BY ${sortExpression} ${orderDir}, p.id ${orderDir} - LIMIT ?` - ) - .all( - ...(ratingSort ? [voterId] : []), - pattern, - after.v, - after.i, - limit - ) as Pokemon[]; - } else { - items = db - .prepare( - `SELECT p.id, p.name, p.attack, p.defence, p.special_attack, p.special_defence, p.speed, p.hp${ratingSortColumn} - FROM ${pokemonSource} WHERE p.name LIKE ? - ORDER BY ${sortExpression} ${orderDir}, p.id ${orderDir} - LIMIT ?` - ) - .all( - ...(ratingSort ? [voterId] : []), - pattern, - limit - ) as Pokemon[]; - } - } - - const hasNextPage = items.length > first; - const resultItems = hasNextPage ? items.slice(0, first) : items; - const endCursor = - resultItems.length > 0 - ? encodeCursor( - ratingSort - ? ( - resultItems[ - resultItems.length - 1 - ] as Pokemon & { rating_sort: number } - ).rating_sort - : (resultItems[resultItems.length - 1] as any)[ - orderColumn - ], - resultItems[resultItems.length - 1].id - ) - : null; - - return { - items: resultItems, - total, - hasNextPage, - hasPreviousPage, - endCursor, - }; - }, - pokemon: (_parent: unknown, args: { id: number }) => - (db.prepare('SELECT * FROM pokemons WHERE id = ?').get(args.id) as - Pokemon | undefined) ?? null, - }, - - // Nested resolvers: fetch children per pokemon via SQL - Pokemon: { - imageUrl: (parent: Pokemon) => - `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${parent.id}.png`, - types: (parent: Pokemon): PokemonType[] => - db - .prepare('SELECT name FROM types WHERE pokemon_id = ?') - .all(parent.id) as PokemonType[], - abilities: (parent: Pokemon): Ability[] => - db - .prepare( - 'SELECT name, description, short_description FROM abilities WHERE pokemon_id = ?' - ) - .all(parent.id) as Ability[], - averageRating: (parent: Pokemon): number | null => - getRatingStats(parent.id).average, - ratingCount: (parent: Pokemon): number => - getRatingStats(parent.id).votes, - }, -}; - const server = new ApolloServer({ typeDefs, resolvers, diff --git a/project2/server/schema.ts b/project2/server/schema.ts index dea176b..9a06d6a 100644 --- a/project2/server/schema.ts +++ b/project2/server/schema.ts @@ -9,11 +9,6 @@ export function decodeCursor(cursor: string): { v: unknown; i: number } { return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')); } -/** - * Aggregated community rating for one Pokémon. - * AVG returns NULL when there are no ratings, which becomes a null - * `averageRating` in GraphQL. COUNT returns 0 in that case. - */ interface RatingStats { average: number | null; votes: number; @@ -62,36 +57,75 @@ export const typeDefs = `#graphql endCursor: String } - enum PokemonSortField { - ID - NAME - HP - ATTACK - DEFENCE - SPECIAL_ATTACK - SPECIAL_DEFENCE - SPEED - } - - enum SortOrder { - ASC - DESC - } - type Query { pokemons( search: String, first: Int = 10, after: String, filter: String, + voterId: ID, orderBy: String, orderDir: String ): PokemonPage! pokemon(id: Int!): Pokemon } + + type Mutation { + ratePokemon(pokemonId: Int!, voterId: ID!, value: Int!): Pokemon! + } `; +const allowedColumns = [ + 'id', + 'name', + 'attack', + 'defence', + 'special_attack', + 'special_defence', + 'speed', + 'hp', +]; + export const resolvers = { + Mutation: { + ratePokemon: ( + _parent: unknown, + args: { pokemonId: number; voterId: string; value: number } + ) => { + if (!Number.isInteger(args.pokemonId) || args.pokemonId < 1) { + throw new Error('Pokemon ID must be a positive integer.'); + } + if (!args.voterId.trim() || args.voterId.length > 128) { + throw new Error( + 'Voter ID must be between 1 and 128 characters.' + ); + } + if ( + !Number.isInteger(args.value) || + args.value < 1 || + args.value > 5 + ) { + throw new Error('Rating must be an integer between 1 and 5.'); + } + const pokemon = db + .prepare('SELECT * FROM pokemons WHERE id = ?') + .get(args.pokemonId) as Pokemon | undefined; + if (!pokemon) { + throw new Error('Pokemon not found.'); + } + + db.prepare( + ` + INSERT INTO ratings (pokemon_id, voter_id, rating) + VALUES (?, ?, ?) + ON CONFLICT (pokemon_id, voter_id) + DO UPDATE SET rating = excluded.rating + ` + ).run(args.pokemonId, args.voterId, args.value); + + return pokemon; + }, + }, Query: { pokemons: ( _parent: unknown, @@ -100,6 +134,7 @@ export const resolvers = { first?: number; after?: string; filter?: string; + voterId?: string; orderBy?: string; orderDir?: string; } @@ -110,138 +145,102 @@ export const resolvers = { const orderBy = args.orderBy?.trim() ?? ''; const orderDir = args.orderDir?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; - const pattern = `%${search}%`; - const allowedColumns = [ - 'id', - 'name', - 'attack', - 'defence', - 'special_attack', - 'special_defence', - 'speed', - 'hp', - ]; const orderColumn = allowedColumns.includes(orderBy) ? orderBy : 'id'; + const ratingSort = orderBy === 'rating'; + const voterId = args.voterId ?? ''; + const sortExpression = ratingSort + ? 'COALESCE(r.rating, 0)' + : `p.${orderColumn}`; + const source = ratingSort + ? 'pokemons p LEFT JOIN ratings r ON r.pokemon_id = p.id AND r.voter_id = ?' + : 'pokemons p'; + const selectRatingSort = ratingSort + ? ', COALESCE(r.rating, 0) AS rating_sort' + : ''; + const conditions = ['p.name LIKE ?']; + const conditionParameters: unknown[] = [`%${search}%`]; + const typeJoin = filter + ? ' JOIN types t ON p.id = t.pokemon_id' + : ''; + if (filter) { + conditions.push('t.name = ?'); + conditionParameters.push(filter); + } - let after: { v: any; i: number } | null = null; + let after: { v: unknown; i: number } | null = null; if (args.after) { try { - after = decodeCursor(args.after); + const decoded = decodeCursor(args.after); + if ( + typeof decoded.i === 'number' && + Number.isInteger(decoded.i) + ) { + after = decoded; + } } catch { - // ignore invalid cursor + after = null; } } - const op = orderDir === 'ASC' ? '>' : '<'; - const invOp = orderDir === 'ASC' ? '<' : '>'; - const limit = first + 1; + const comparison = orderDir === 'ASC' ? '>' : '<'; + const oppositeComparison = orderDir === 'ASC' ? '<' : '>'; + const cursorCondition = after + ? ` AND (${sortExpression}, p.id) ${comparison} (?, ?)` + : ''; + const cursorParameters = after ? [after.v, after.i] : []; + const queryParameters = [ + ...(ratingSort ? [voterId] : []), + ...conditionParameters, + ...cursorParameters, + first + 1, + ]; + const countParameters = [...conditionParameters]; + const countSql = filter + ? `SELECT COUNT(DISTINCT p.id) AS n FROM pokemons p${typeJoin} WHERE ${conditions.join(' AND ')}` + : `SELECT COUNT(*) AS n FROM pokemons p WHERE ${conditions.join(' AND ')}`; + const total = ( + db.prepare(countSql).get(...countParameters) as { n: number } + ).n; - let total: number; - let items: Pokemon[]; let hasPreviousPage = false; - - if (filter) { - total = ( - db - .prepare( - 'SELECT COUNT(DISTINCT p.id) AS n FROM pokemons p JOIN types t ON p.id = t.pokemon_id WHERE p.name LIKE ? AND t.name = ?' - ) - .get(pattern, filter) as { n: number } - ).n; - - if (after) { - hasPreviousPage = - ( - db - .prepare( - `SELECT COUNT(DISTINCT p.id) AS n FROM pokemons p JOIN types t ON p.id = t.pokemon_id WHERE p.name LIKE ? AND t.name = ? AND (p.${orderColumn}, p.id) ${invOp} (?, ?)` - ) - .get(pattern, filter, after.v, after.i) as { - n: number; - } - ).n > 0; - - items = db - .prepare( - `SELECT DISTINCT p.id, p.name, p.attack, p.defence, p.special_attack, p.special_defence, p.speed, p.hp - FROM pokemons p - JOIN types t ON p.id = t.pokemon_id - WHERE p.name LIKE ? AND t.name = ? AND (p.${orderColumn}, p.id) ${op} (?, ?) - ORDER BY p.${orderColumn} ${orderDir}, p.id ${orderDir} - LIMIT ?` - ) - .all( - pattern, - filter, - after.v, - after.i, - limit - ) as Pokemon[]; - } else { - items = db - .prepare( - `SELECT DISTINCT p.id, p.name, p.attack, p.defence, p.special_attack, p.special_defence, p.speed, p.hp - FROM pokemons p - JOIN types t ON p.id = t.pokemon_id - WHERE p.name LIKE ? AND t.name = ? - ORDER BY p.${orderColumn} ${orderDir}, p.id ${orderDir} - LIMIT ?` - ) - .all(pattern, filter, limit) as Pokemon[]; - } - } else { - total = ( - db - .prepare( - 'SELECT COUNT(*) AS n FROM pokemons WHERE name LIKE ?' - ) - .get(pattern) as { n: number } - ).n; - - if (after) { - hasPreviousPage = - ( - db - .prepare( - `SELECT COUNT(*) AS n FROM pokemons WHERE name LIKE ? AND (${orderColumn}, id) ${invOp} (?, ?)` - ) - .get(pattern, after.v, after.i) as { n: number } - ).n > 0; - - items = db - .prepare( - `SELECT id, name, attack, defence, special_attack, special_defence, speed, hp - FROM pokemons - WHERE name LIKE ? AND (${orderColumn}, id) ${op} (?, ?) - ORDER BY ${orderColumn} ${orderDir}, id ${orderDir} - LIMIT ?` - ) - .all(pattern, after.v, after.i, limit) as Pokemon[]; - } else { - items = db - .prepare( - `SELECT id, name, attack, defence, special_attack, special_defence, speed, hp - FROM pokemons WHERE name LIKE ? - ORDER BY ${orderColumn} ${orderDir}, id ${orderDir} - LIMIT ?` - ) - .all(pattern, limit) as Pokemon[]; - } + if (after) { + const previousSql = filter + ? `SELECT COUNT(DISTINCT p.id) AS n FROM ${source}${typeJoin} WHERE ${conditions.join(' AND ')} AND (${sortExpression}, p.id) ${oppositeComparison} (?, ?)` + : `SELECT COUNT(*) AS n FROM ${source} WHERE ${conditions.join(' AND ')} AND (${sortExpression}, p.id) ${oppositeComparison} (?, ?)`; + hasPreviousPage = + ( + db + .prepare(previousSql) + .get( + ...(ratingSort ? [voterId] : []), + ...conditionParameters, + ...cursorParameters + ) as { n: number } + ).n > 0; } + const itemsSql = ` + SELECT ${filter ? 'DISTINCT ' : ''}p.id, p.name, p.attack, p.defence, p.special_attack, p.special_defence, p.speed, p.hp${selectRatingSort} + FROM ${source}${typeJoin} + WHERE ${conditions.join(' AND ')}${cursorCondition} + ORDER BY ${sortExpression} ${orderDir}, p.id ${orderDir} + LIMIT ?`; + const items = db + .prepare(itemsSql) + .all(...queryParameters) as Pokemon[]; const hasNextPage = items.length > first; const resultItems = hasNextPage ? items.slice(0, first) : items; - const endCursor = - resultItems.length > 0 - ? encodeCursor( - (resultItems[resultItems.length - 1] as any)[ - orderColumn - ], - resultItems[resultItems.length - 1].id - ) - : null; + const lastItem = resultItems[resultItems.length - 1] as + | (Pokemon & { rating_sort?: number } & Record) + | undefined; + const endCursor = lastItem + ? encodeCursor( + ratingSort ? lastItem.rating_sort : lastItem[orderColumn], + lastItem.id + ) + : null; return { items: resultItems, @@ -255,8 +254,6 @@ export const resolvers = { (db.prepare('SELECT * FROM pokemons WHERE id = ?').get(args.id) as Pokemon | undefined) ?? null, }, - - // Nested resolvers: fetch children per pokemon via SQL Pokemon: { imageUrl: (parent: Pokemon) => `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${parent.id}.png`, diff --git a/project2/server/test/api.test.ts b/project2/server/test/api.test.ts index 9155dc6..d9a3256 100644 --- a/project2/server/test/api.test.ts +++ b/project2/server/test/api.test.ts @@ -24,7 +24,11 @@ const mockTypes = [{ name: 'Grass' }, { name: 'Poison' }]; const mockAbilities = [{ name: 'Overgrow', description: 'Powers up Grass-type moves in a pinch.', short_description: 'Powers up Grass-type moves in a pinch.' }]; function mockStatement(result: unknown) { - return { get: vi.fn(() => result), all: vi.fn(() => (Array.isArray(result) ? result : [])) }; + return { + get: vi.fn(() => result), + all: vi.fn(() => (Array.isArray(result) ? result : [])), + run: vi.fn(), + }; } function setupDbMock(options: { @@ -35,14 +39,18 @@ function setupDbMock(options: { }) { mockedDb.prepare.mockImplementation((sql: string) => { const s = sql.toLowerCase(); + if (s.includes('insert into ratings')) return mockStatement(null); + if (s.includes('count(distinct p.id) as n')) { + if (s.includes('and (')) return mockStatement({ n: options.previousCount ?? 0 }); + return mockStatement({ n: options.filteredCount ?? options.totalCount ?? 0 }); + } if (s.includes('count(*) as n')) { - if (s.includes('distinct')) return mockStatement({ n: options.filteredCount ?? options.totalCount ?? 0 }); if (s.includes('and (')) return mockStatement({ n: options.previousCount ?? 0 }); if (s.includes('like')) return mockStatement({ n: options.filteredCount ?? options.totalCount ?? 0 }); return mockStatement({ n: options.totalCount ?? 0 }); } if (s.includes('select distinct p.id')) return mockStatement(options.items ?? mockPokemons); - if (s.includes('select id, name,')) return mockStatement(options.items ?? mockPokemons); + if (s.includes('select p.id, p.name,')) return mockStatement(options.items ?? mockPokemons); if (s.includes('select * from pokemons where id =')) return mockStatement(options.pokemonById ?? null); if (s.includes('select name from types')) return mockStatement(options.types ?? []); if (s.includes('select name, description, short_description from abilities')) return mockStatement(options.abilities ?? []); @@ -183,6 +191,104 @@ describe('GraphQL API', () => { }); }); + describe('ratePokemon mutation', () => { + it('returns the pokemon and upserts its rating', async () => { + setupDbMock({ pokemonById: mockPokemons[0] }); + const result = await executeQuery( + server, + 'mutation { ratePokemon(pokemonId: 1, voterId: "voter-1", value: 4) { id name } }' + ); + expect(result.errors).toBeUndefined(); + expect((result.data as any).ratePokemon.id).toBe(1); + const ratingStatement = mockedDb.prepare.mock.results.find( + (_, i) => + String(mockedDb.prepare.mock.calls[i][0]) + .toLowerCase() + .includes('insert into ratings') + ); + expect(ratingStatement).toBeDefined(); + expect( + (ratingStatement!.value as ReturnType).run + ).toHaveBeenCalledWith(1, 'voter-1', 4); + }); + + it.each([ + ['rejects a rating below 1', 0, 'voter-1'], + ['rejects a rating above 5', 6, 'voter-1'], + ['rejects an empty voter ID', 4, ''], + ])('%s', async (_name, value, voterId) => { + setupDbMock({ pokemonById: mockPokemons[0] }); + const result = await executeQuery( + server, + `mutation { ratePokemon(pokemonId: 1, voterId: "${voterId}", value: ${value}) { id } }` + ); + expect(result.errors).toBeDefined(); + }); + + it('rejects a non-integer rating', async () => { + setupDbMock({ pokemonById: mockPokemons[0] }); + const result = await executeQuery( + server, + 'mutation { ratePokemon(pokemonId: 1, voterId: "voter-1", value: 4.5) { id } }' + ); + expect(result.errors).toBeDefined(); + }); + + it('rejects an unknown pokemon ID', async () => { + setupDbMock({ pokemonById: null }); + const result = await executeQuery( + server, + 'mutation { ratePokemon(pokemonId: 999, voterId: "voter-1", value: 4) { id } }' + ); + expect(result.errors).toBeDefined(); + }); + }); + + describe('advanced pokemon pagination', () => { + it('passes voterId first when sorting by rating', async () => { + setupDbMock({ items: [mockPokemons[2], mockPokemons[0]] }); + const result = await executeQuery( + server, + '{ pokemons(first: 2, voterId: "voter-1", orderBy: "rating") { items { id } } }' + ); + expect(result.errors).toBeUndefined(); + expect((result.data as any).pokemons.items[0].id).toBe(3); + const query = mockedDb.prepare.mock.results.find( + (_, i) => + String(mockedDb.prepare.mock.calls[i][0]) + .toLowerCase() + .includes('left join ratings') + ); + expect(query).toBeDefined(); + expect( + (query!.value as ReturnType).all + ).toHaveBeenCalledWith('voter-1', '%%', 3); + }); + + it('combines type filter, cursor, and sorting', async () => { + const cursor = encodeCursor(50, 2); + setupDbMock({ filteredCount: 2, previousCount: 1, items: [mockPokemons[3]] }); + const result = await executeQuery( + server, + 'query ($after: String) { pokemons(filter: "Fire", first: 1, after: $after, orderBy: "attack") { items { id } total hasPreviousPage } }', + { after: cursor } + ); + expect(result.errors).toBeUndefined(); + expect((result.data as any).pokemons.total).toBe(2); + expect((result.data as any).pokemons.hasPreviousPage).toBe(true); + const query = mockedDb.prepare.mock.results.find( + (_, i) => + String(mockedDb.prepare.mock.calls[i][0]) + .toLowerCase() + .includes('select distinct p.id') + ); + expect(query).toBeDefined(); + expect( + (query!.value as ReturnType).all + ).toHaveBeenCalledWith('%%', 'Fire', 50, 2, 2); + }); + }); + describe('pokemon query', () => { it('returns a single pokemon by id', async () => { setupDbMock({ pokemonById: mockPokemons[0] });