From a2ca2af49bd59a4145a6dc4190b5b9a5b05098b3 Mon Sep 17 00:00:00 2001 From: siversm Date: Tue, 10 Nov 2026 10:10:43 +0100 Subject: [PATCH] feat: add average rating and vote count to the GraphQL API Add averageRating (null when unrated) and ratingCount fields to the Pokemon type, resolved from the ratings table. ratePokemon still returns Int; the return type changes in the frontend issue. Issue: #57 Co Author: Claude Opus 5.5 @pederoha --- docs/AI decleration.md | 2 ++ project2/server/index.ts | 24 +++++++++++++++ project2/server/schema.ts | 24 +++++++++++++++ project2/server/test/api.test.ts | 51 ++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+) diff --git a/docs/AI decleration.md b/docs/AI decleration.md index e698592..d49fdb3 100644 --- a/docs/AI decleration.md +++ b/docs/AI decleration.md @@ -39,3 +39,5 @@ final implementation and documentation. Copilot GPT Luna 5.6 assisted with spelling, formatting, and fact-checking the sustainability documentation. The content and final wording were reviewed by the team. +- **`#57` - Rating system changes (10 November 2026):** + `Claude Opus 5.5` was consulted for rating system changes in order for logic to have fewer flaws, work with the current frontend implementations and generate tests for the new aggregate system. diff --git a/project2/server/index.ts b/project2/server/index.ts index 8eecd1d..991195b 100644 --- a/project2/server/index.ts +++ b/project2/server/index.ts @@ -62,6 +62,8 @@ const typeDefs = `#graphql speed: Int! hp: Int! imageUrl: String! + averageRating: Float + ratingCount: Int! } type PokemonPage { @@ -118,6 +120,24 @@ const SORT_COLUMNS = { 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: ( @@ -377,6 +397,10 @@ const resolvers = { '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, }, }; diff --git a/project2/server/schema.ts b/project2/server/schema.ts index 450208f..dea176b 100644 --- a/project2/server/schema.ts +++ b/project2/server/schema.ts @@ -9,6 +9,24 @@ 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; +} + +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; +} + export const typeDefs = `#graphql type Ability { name: String! @@ -32,6 +50,8 @@ export const typeDefs = `#graphql speed: Int! hp: Int! imageUrl: String! + averageRating: Float + ratingCount: Int! } type PokemonPage { @@ -250,5 +270,9 @@ export const resolvers = { '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, }, }; diff --git a/project2/server/test/api.test.ts b/project2/server/test/api.test.ts index a956c1e..9155dc6 100644 --- a/project2/server/test/api.test.ts +++ b/project2/server/test/api.test.ts @@ -31,6 +31,7 @@ function setupDbMock(options: { totalCount?: number; filteredCount?: number; items?: typeof mockPokemons; previousCount?: number; pokemonById?: (typeof mockPokemons)[0] | null; types?: typeof mockTypes; abilities?: typeof mockAbilities; + ratingStats?: { average: number | null; votes: number }; }) { mockedDb.prepare.mockImplementation((sql: string) => { const s = sql.toLowerCase(); @@ -45,6 +46,8 @@ function setupDbMock(options: { 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 ?? []); + // Mirrors SQLite: AVG is NULL and COUNT is 0 when no ratings exist + if (s.includes('select avg(rating)')) return mockStatement(options.ratingStats ?? { average: null, votes: 0 }); return mockStatement([]); }); } @@ -221,4 +224,52 @@ describe('GraphQL API', () => { expect((result.data as any).pokemon.abilities[0].name).toBe('Overgrow'); }); }); + + describe('Pokemon rating aggregate', () => { + it('returns null average and zero count when there are no ratings', async () => { + setupDbMock({ pokemonById: mockPokemons[0] }); + const result = await executeQuery(server, '{ pokemon(id: 1) { averageRating ratingCount } }'); + expect(result.errors).toBeUndefined(); + expect((result.data as any).pokemon.averageRating).toBeNull(); + expect((result.data as any).pokemon.ratingCount).toBe(0); + }); + + it('returns the single rating as the average when there is one vote', async () => { + setupDbMock({ pokemonById: mockPokemons[0], ratingStats: { average: 4, votes: 1 } }); + const result = await executeQuery(server, '{ pokemon(id: 1) { averageRating ratingCount } }'); + expect(result.errors).toBeUndefined(); + expect((result.data as any).pokemon.averageRating).toBe(4); + expect((result.data as any).pokemon.ratingCount).toBe(1); + }); + + it('returns a fractional average for several votes', async () => { + setupDbMock({ pokemonById: mockPokemons[0], ratingStats: { average: 3.5, votes: 4 } }); + const result = await executeQuery(server, '{ pokemon(id: 1) { averageRating ratingCount } }'); + expect(result.errors).toBeUndefined(); + expect((result.data as any).pokemon.averageRating).toBe(3.5); + expect((result.data as any).pokemon.ratingCount).toBe(4); + }); + + it('aggregates ratings for the requested pokemon id', async () => { + setupDbMock({ pokemonById: mockPokemons[2], ratingStats: { average: 5, votes: 2 } }); + await executeQuery(server, '{ pokemon(id: 3) { averageRating } }'); + const ratingCall = mockedDb.prepare.mock.results.find((_, i) => + String(mockedDb.prepare.mock.calls[i][0]).toLowerCase().includes('select avg(rating)') + ); + expect(ratingCall).toBeDefined(); + expect((ratingCall!.value as ReturnType).get).toHaveBeenCalledWith(3); + }); + + it('returns the aggregate for every pokemon in a page', async () => { + setupDbMock({ totalCount: 2, items: mockPokemons.slice(0, 2), ratingStats: { average: 2.5, votes: 2 } }); + const result = await executeQuery(server, '{ pokemons(first: 2) { items { id averageRating ratingCount } } }'); + expect(result.errors).toBeUndefined(); + const items = (result.data as any).pokemons.items; + expect(items).toHaveLength(2); + for (const item of items) { + expect(item.averageRating).toBe(2.5); + expect(item.ratingCount).toBe(2); + } + }); + }); });