From e48cd252e769779bb4495ee5ff8d7d1e5045a23e Mon Sep 17 00:00:00 2001 From: Tobias Hegge Date: Tue, 10 Nov 2026 13:08:35 +0100 Subject: [PATCH] updated API-call to only fetch relevan reviews instead of filtering in frontend after fetching the entire table --- .../backend/src/graphql/resolvers.ts | 27 ++++++++- T04-Project-2/backend/src/graphql/schema.ts | 3 +- T04-Project-2/backend/tests/reviews.test.ts | 57 +++++++++++++++++++ T04-Project-2/frontend/src/api/games.ts | 21 +++---- .../frontend/src/components/Top5Games.tsx | 3 +- .../tests/components/GameReviewsApi.test.tsx | 30 ++++++++++ 6 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 T04-Project-2/backend/tests/reviews.test.ts create mode 100644 T04-Project-2/frontend/tests/components/GameReviewsApi.test.tsx diff --git a/T04-Project-2/backend/src/graphql/resolvers.ts b/T04-Project-2/backend/src/graphql/resolvers.ts index 2aed673..e73af77 100644 --- a/T04-Project-2/backend/src/graphql/resolvers.ts +++ b/T04-Project-2/backend/src/graphql/resolvers.ts @@ -63,8 +63,22 @@ export const resolvers = { username: users.username, }).from(users); }, - reviews: async () => { - return db.select().from(reviews).orderBy(desc(reviews.published_time)); + reviews: async (_parent: unknown, { gameId }: { gameId: string }) => { + const parsedGameId = Number(gameId); + if (!Number.isSafeInteger(parsedGameId) || parsedGameId <= 0) { + throw new Error(`Invalid game ID: ${gameId}`); + } + + return db.select({ + user_id: reviews.user_id, + game_id: reviews.game_id, + description: reviews.description, + published_time: reviews.published_time, + authorName: users.username, + }).from(reviews) + .innerJoin(users, eq(reviews.user_id, users.id)) + .where(eq(reviews.game_id, parsedGameId)) + .orderBy(desc(reviews.published_time)); }, favorites: async () => { return db.select().from(favorites); @@ -376,6 +390,15 @@ export const resolvers = { .where(eq(game_publishers.game_id, game.id)), platforms: platformsForGame }, + Review: { + authorName: async (review: { user_id: number; authorName?: string }) => { + if (review.authorName !== undefined) return review.authorName; + // Newly created reviews do not include the joined username yet. + const [author] = await db.select({ username: users.username }) + .from(users).where(eq(users.id, review.user_id)).limit(1); + return author?.username ?? "Unknown player"; + }, + }, GameSummary: { platforms: platformsForGame, }, diff --git a/T04-Project-2/backend/src/graphql/schema.ts b/T04-Project-2/backend/src/graphql/schema.ts index 49a35b0..31c46d5 100644 --- a/T04-Project-2/backend/src/graphql/schema.ts +++ b/T04-Project-2/backend/src/graphql/schema.ts @@ -71,6 +71,7 @@ export const typeDefs = /* GraphQL */ ` } type Review { + authorName: String! user_id: ID! game_id: ID! description: String! @@ -90,7 +91,7 @@ export const typeDefs = /* GraphQL */ ` publishers: [Publisher!]! platforms: [Platform!]! users: [User!]! - reviews: [Review!]! + reviews(gameId: ID!): [Review!]! favorites: [Favorite!]! reviewCount(gameId: ID!): Int! favoriteCount(gameId: ID!): Int! diff --git a/T04-Project-2/backend/tests/reviews.test.ts b/T04-Project-2/backend/tests/reviews.test.ts new file mode 100644 index 0000000..0294ccd --- /dev/null +++ b/T04-Project-2/backend/tests/reviews.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { afterEach, mock, test } from "node:test"; +import { buildSchema, parse, validate } from "graphql"; +import { typeDefs } from "../src/graphql/schema"; + +process.env.DATABASE_URL = "postgres://unused:unused@127.0.0.1:1/unused"; +const modules = Promise.all([import("../src/db/index.js"), import("../src/graphql/resolvers.js")]); +afterEach(() => mock.restoreAll()); + +test("reviews requires gameId in the GraphQL query", () => { + const schema = buildSchema(typeDefs); + assert.ok(validate(schema, parse("{ reviews { description } }")).length > 0); + assert.equal(validate(schema, parse('{ reviews(gameId: "42") { description authorName } }')).length, 0); +}); + +test("reviews filters by game ID and sorts newest first in SQL", async () => { + const [{ pool }, { resolvers }] = await modules; + let sql = ""; + let parameters: unknown[] = []; + mock.method(pool, "query", async (query: { text: string }, values: unknown[]) => { + sql = query.text; + parameters = values; + return { rows: [[7, 42, "Great game", new Date("2026-01-01T00:00:00Z"), "Alice"]] }; + }); + const result = await resolvers.Query.reviews(null, { gameId: "42" }); + assert.equal(result.length, 1); + assert.equal(result[0]?.authorName, "Alice"); + assert.equal(result[0]?.game_id, 42); + assert.match(sql, /inner join "users" on "reviews"\."user_id" = "users"\."id"/); + assert.ok(!sql.includes('"password"')); + assert.match(sql, /where "reviews"\."game_id" = \$1/); + assert.deepEqual(parameters, [42]); + assert.match(sql, /order by "reviews"\."published_time" desc/); +}); + +test("reviews rejects invalid game IDs before accessing the database", async () => { + const [{ pool }, { resolvers }] = await modules; + const query = mock.method(pool, "query", () => { throw new Error("Unexpected database access"); }); + for (const gameId of ["", "0", "-1", "1.5", "abc", "9007199254740992"]) { + await assert.rejects(resolvers.Query.reviews(null, { gameId }), /Invalid game ID/); + } + assert.equal(query.mock.callCount(), 0); +}); + + +test("joined author names need no extra database query", async () => { + const [{ pool }, { resolvers }] = await modules; + const query = mock.method(pool, "query", () => { throw new Error("Unexpected database access"); }); + assert.equal(await resolvers.Review.authorName({ user_id: 7, authorName: "Alice" }), "Alice"); + assert.equal(query.mock.callCount(), 0); +}); + +test("newly created reviews can also resolve their author name", async () => { + const [{ pool }, { resolvers }] = await modules; + mock.method(pool, "query", async () => ({ rows: [["Alice"]] })); + assert.equal(await resolvers.Review.authorName({ user_id: 7 }), "Alice"); +}); diff --git a/T04-Project-2/frontend/src/api/games.ts b/T04-Project-2/frontend/src/api/games.ts index b78b467..173bf2c 100644 --- a/T04-Project-2/frontend/src/api/games.ts +++ b/T04-Project-2/frontend/src/api/games.ts @@ -113,19 +113,17 @@ export async function fetchGameReviews(gameId: string): Promise { }, body: JSON.stringify({ query: ` - query GameReviews { - reviews { + query GameReviews($gameId: ID!) { + reviews(gameId: $gameId) { user_id game_id description published_time - } - users { - id - username + authorName } } `, + variables: { gameId }, }), }); @@ -139,25 +137,20 @@ export async function fetchGameReviews(gameId: string): Promise { throw new Error(result.errors[0].message); } - const usersById = new Map( - result.data.users.map((user: { id: string; username: string }) => [String(user.id), user.username]), - ); - return result.data.reviews - .filter((review: { game_id: string }) => String(review.game_id) === gameId) .map((review: { user_id: string; game_id: string; description: string; published_time: string; + authorName: string; }) => ({ id: `${review.user_id}-${review.game_id}`, userId: String(review.user_id), content: review.description, createdAt: review.published_time, - authorName: usersById.get(String(review.user_id)) ?? "Unknown player", - })) - .sort((a: GameReview, b: GameReview) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); + authorName: review.authorName, + })); } export async function fetchGameReviewCount(gameId: string): Promise { diff --git a/T04-Project-2/frontend/src/components/Top5Games.tsx b/T04-Project-2/frontend/src/components/Top5Games.tsx index 61a8c9c..f8565b4 100644 --- a/T04-Project-2/frontend/src/components/Top5Games.tsx +++ b/T04-Project-2/frontend/src/components/Top5Games.tsx @@ -1,3 +1,4 @@ +import type { FC } from 'react' import { top5MockGames } from '../data/top5MockGames' import './Top5Games.css' import { SectionHeading } from './SectionHeading' @@ -10,7 +11,7 @@ type Top5GamesProps = { gameIds?: readonly string[]; } -export const Top5Games = ({ gameIds }: Top5GamesProps) => { +export const Top5Games: FC = () => { // Matches the 900px breakpoint in index.css. On narrow screens the review panel // isn't rendered at all, so its autoscroll loop doesn't run either. const isWide = useMediaQuery('(min-width: 900px)') diff --git a/T04-Project-2/frontend/tests/components/GameReviewsApi.test.tsx b/T04-Project-2/frontend/tests/components/GameReviewsApi.test.tsx new file mode 100644 index 0000000..5520919 --- /dev/null +++ b/T04-Project-2/frontend/tests/components/GameReviewsApi.test.tsx @@ -0,0 +1,30 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { fetchGameReviews } from '../../src/api/games' + +afterEach(() => vi.unstubAllGlobals()) + +it('requests reviews for the game and uses author names from the response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: { reviews: [ + { user_id: '8', game_id: '42', description: 'Newest', published_time: '2026-02-01T00:00:00Z', authorName: 'Alice' }, + { user_id: '9', game_id: '42', description: 'Older', published_time: '2026-01-01T00:00:00Z', authorName: 'Bob' }, + ] } }), + }) + vi.stubGlobal('fetch', fetchMock) + const reviews = await fetchGameReviews('42') + const body = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(body.variables).toEqual({ gameId: '42' }) + expect(body.query).toContain('reviews(gameId: $gameId)') + expect(body.query).toContain('authorName') + expect(body.query).not.toMatch(/\busers\s*\{/) + expect(reviews).toEqual([ + { id: '8-42', userId: '8', content: 'Newest', createdAt: '2026-02-01T00:00:00Z', authorName: 'Alice' }, + { id: '9-42', userId: '9', content: 'Older', createdAt: '2026-01-01T00:00:00Z', authorName: 'Bob' }, + ]) +}) + +it('returns an empty list for a game with no reviews', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: { reviews: [] } }) })) + expect(await fetchGameReviews('42')).toEqual([]) +})