Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions T04-Project-2/backend/src/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
},
Expand Down
3 changes: 2 additions & 1 deletion T04-Project-2/backend/src/graphql/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const typeDefs = /* GraphQL */ `
}
type Review {
authorName: String!
user_id: ID!
game_id: ID!
description: String!
Expand All @@ -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!
Expand Down
57 changes: 57 additions & 0 deletions T04-Project-2/backend/tests/reviews.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
21 changes: 7 additions & 14 deletions T04-Project-2/frontend/src/api/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,19 +113,17 @@ export async function fetchGameReviews(gameId: string): Promise<GameReview[]> {
},
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 },
}),
});

Expand All @@ -139,25 +137,20 @@ export async function fetchGameReviews(gameId: string): Promise<GameReview[]> {
throw new Error(result.errors[0].message);
}

const usersById = new Map<string, string>(
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<number> {
Expand Down
3 changes: 2 additions & 1 deletion T04-Project-2/frontend/src/components/Top5Games.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FC } from 'react'
import { top5MockGames } from '../data/top5MockGames'
import './Top5Games.css'
import { SectionHeading } from './SectionHeading'
Expand All @@ -10,7 +11,7 @@ type Top5GamesProps = {
gameIds?: readonly string[];
}

export const Top5Games = ({ gameIds }: Top5GamesProps) => {
export const Top5Games: FC<Top5GamesProps> = () => {
// 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)')
Expand Down
30 changes: 30 additions & 0 deletions T04-Project-2/frontend/tests/components/GameReviewsApi.test.tsx
Original file line number Diff line number Diff line change
@@ -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([])
})