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
19 changes: 19 additions & 0 deletions T04-Project-2/backend/src/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ export const resolvers = {
favorites: async () => {
return db.select().from(favorites);
},
userFavorites: async (_parent: unknown, { userId }: { userId: string }) => {
const id = Number(userId);
if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`Invalid user ID: ${userId}`);
const rows = await db.select({ gameId: favorites.game_id }).from(favorites)
.where(eq(favorites.user_id, id)).orderBy(asc(favorites.game_id));
return rows.map((row) => row.gameId);
},
reviewCount: async (_parent: unknown, { gameId }: { gameId: string }) => {
const parsedGameId = Number(gameId);
if (!Number.isSafeInteger(parsedGameId) || parsedGameId <= 0) {
Expand Down Expand Up @@ -297,6 +304,18 @@ export const resolvers = {
if (!user || !await verifyPassword(password, user.password)) return null;
return { id: user.id, username: user.username };
},
setFavorite: async (_parent: unknown, { userId, gameId, favorite }: { userId: string; gameId: string; favorite: boolean }) => {
const user = Number(userId);
if (!Number.isSafeInteger(user) || user <= 0) throw new Error(`Invalid user ID: ${userId}`);
const id = Number(gameId);
if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`Invalid game ID: ${gameId}`);
if (favorite) {
await db.insert(favorites).values({ user_id: user, game_id: id }).onConflictDoNothing();
} else {
await db.delete(favorites).where(and(eq(favorites.user_id, user), eq(favorites.game_id, id)));
}
return true;
},
createReview: async (
_parent: unknown,
{ gameId, description }: { gameId: string; description: string },
Expand Down
2 changes: 2 additions & 0 deletions T04-Project-2/backend/src/graphql/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const typeDefs = /* GraphQL */ `
users: [User!]!
reviews(gameId: ID!): [Review!]!
favorites: [Favorite!]!
userFavorites(userId: ID!): [ID!]!
reviewCount(gameId: ID!): Int!
favoriteCount(gameId: ID!): Int!
searchGames(
Expand All @@ -111,6 +112,7 @@ export const typeDefs = /* GraphQL */ `
type Mutation {
createUser(username: String!, password: String!): User
login(username: String!, password: String!): User
setFavorite(userId: ID!, gameId: ID!, favorite: Boolean!): Boolean!
createReview(gameId: ID!, description: String!): Review
deleteReview(gameId: ID!): Boolean!
}
Expand Down
42 changes: 42 additions & 0 deletions T04-Project-2/frontend/src/api/favorites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
async function favoriteRequest<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
const response = await fetch(import.meta.env.VITE_API_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
if (!response.ok) throw new Error(`HTTP error: ${response.status}`)
const result = await response.json()
if (result.errors?.length) throw new Error(result.errors[0].message)
if (!result.data) throw new Error('The server returned no favorites result.')
return result.data as T
}

export async function fetchFavoriteIds(userId: string): Promise<string[]> {
const data = await favoriteRequest<{ userFavorites: string[] }>('query UserFavorites($userId: ID!) { userFavorites(userId: $userId) }', { userId })
const ids = data.userFavorites.map(String)
// Transfer the old browser-only favorites once, keeping them if any request fails.
const legacyKey = `gaminggoat:favorites:${userId}`
let legacy: string | null
try {
legacy = window.localStorage.getItem(legacyKey)
} catch {
return ids
}
if (legacy === null) return ids
const parsed: unknown = JSON.parse(legacy)
if (!Array.isArray(parsed) || !parsed.every((id) => typeof id === 'string' && /^[1-9]\d*$/.test(id))) {
throw new Error('Saved browser favorites have an invalid format.')
}
const missing = [...new Set(parsed as string[])].filter((id) => !ids.includes(id))
for (const id of missing) await setFavorite(userId, id, true)
try {
if (window.localStorage.getItem(legacyKey) === legacy) window.localStorage.removeItem(legacyKey)
} catch {
// The database remains the source of truth even if browser storage is unavailable.
}
return [...ids, ...missing]
}

export async function setFavorite(userId: string, gameId: string, favorite: boolean): Promise<void> {
await favoriteRequest('mutation SetFavorite($userId: ID!, $gameId: ID!, $favorite: Boolean!) { setFavorite(userId: $userId, gameId: $gameId, favorite: $favorite) }', { userId, gameId, favorite })
}
3 changes: 3 additions & 0 deletions T04-Project-2/frontend/src/components/FavoritesContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export type FavoritesContextValue = {
userId: string | null;
favoriteIds: string[];
storageError: string | null;
isLoading: boolean;
isUpdating: boolean;
isUnavailable: boolean;
toggleFavorite: (gameId: string) => void;
}

Expand Down
5 changes: 2 additions & 3 deletions T04-Project-2/frontend/src/components/FavoritesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useFavorites } from './useFavorites'
import { GameCard } from './GameCard'

function FavoritesPage() {
const { userId, favoriteIds, storageError } = useFavorites();
const { userId, favoriteIds, storageError, isLoading } = useFavorites();
const favoritesQuery = useQuery({
...gameQueryOptions.favoriteByIds(favoriteIds),
enabled: Boolean(userId && favoriteIds.length),
Expand All @@ -21,15 +21,14 @@ function FavoritesPage() {
<p>{userId ? 'Games you have saved to your collection.' : 'Log in to save and view your favorite games.'}</p>
</header>

{storageError && <p role="alert">Could not access saved favorites: {storageError}</p>}
{!userId ? (
<section className="favorites-empty-state">
<span className="favorites-heart" aria-hidden="true"><StarIcon /></span>
<h2>Log in to view favorites</h2>
<p>Your saved games are associated with your account.</p>
<Link className="favorites-browse-link" to="/login">Log in</Link>
</section>
) : favoritesQuery.isPending && favoriteIds.length > 0 ? (
) : storageError ? null : isLoading || (favoritesQuery.isPending && favoriteIds.length > 0) ? (
<p role="status">Loading your favorites…</p>
) : favoritesQuery.error ? (
<p role="alert">Could not load your favorite games: {favoritesQuery.error.message}</p>
Expand Down
76 changes: 28 additions & 48 deletions T04-Project-2/frontend/src/components/FavoritesProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,35 @@
import { useState, type ReactNode } from 'react'
import { FavoritesContext, type FavoritesState } from './FavoritesContext'

function readFavorites(userId: string | null): FavoritesState {
if (!userId) return { ids: [], error: null };

try {
const stored = window.localStorage.getItem(`gaminggoat:favorites:${userId}`);
if (!stored) return { ids: [], error: null };

const ids: unknown = JSON.parse(stored);
if (!Array.isArray(ids) || !ids.every((id) => typeof id === 'string' && /^\d+$/.test(id))) {
throw new Error('Saved favorites have an invalid format.');
}

return { ids, error: null };
} catch (error) {
return {
ids: [],
error: error instanceof Error ? error.message : 'Could not read saved favorites.',
};
}
}
import type { ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchFavoriteIds, setFavorite } from '../api/favorites'
import { FavoritesContext } from './FavoritesContext'

export function FavoritesProvider({ userId, children }: { userId: string | null; children: ReactNode }) {
const [state, setState] = useState(() => readFavorites(userId));

const client = useQueryClient()
const key = ['favorites', 'by-user', userId] as const
const favorites = useQuery({
queryKey: key,
queryFn: () => fetchFavoriteIds(userId!),
enabled: Boolean(userId),
})
const mutation = useMutation({
mutationFn: ({ gameId, favorite }: { gameId: string; favorite: boolean }) => setFavorite(userId!, gameId, favorite),
onSuccess: async (_result, { gameId }) => {
await Promise.all([
client.invalidateQueries({ queryKey: key }),
client.invalidateQueries({ queryKey: ['favorites', 'count-by-game-id', gameId] }),
])
},
})
const favoriteIds = userId ? favorites.data ?? [] : []
const isLoading = Boolean(userId && favorites.isPending)
const toggleFavorite = (gameId: string) => {
if (!userId || !/^\d+$/.test(gameId)) return;

const ids = state.ids.includes(gameId)
? state.ids.filter((id) => id !== gameId)
: [...state.ids, gameId];

try {
window.localStorage.setItem(`gaminggoat:favorites:${userId}`, JSON.stringify(ids));
setState({ ids, error: null });
} catch (error) {
setState({
...state,
error: error instanceof Error ? error.message : 'Could not save favorites.',
});
}
};

if (!userId || !/^\d+$/.test(gameId) || isLoading || favorites.error || mutation.isPending) return
mutation.mutate({ gameId, favorite: !favoriteIds.includes(gameId) })
}
const error = favorites.error ?? mutation.error
return (
<FavoritesContext.Provider value={{
userId,
favoriteIds: state.ids,
storageError: state.error,
toggleFavorite,
}}>
<FavoritesContext.Provider value={{ userId, favoriteIds, storageError: error?.message ?? null, isLoading, isUpdating: mutation.isPending, isUnavailable: Boolean(favorites.error), toggleFavorite }}>
{error && <p role="alert">Could not update or load favorites: {error.message}</p>}
{children}
</FavoritesContext.Provider>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function getDescriptionText(description: string | null): string {

export const FeaturedCarousel = ({gameIds}: FeaturedCarouselProps) => {
const { data: games = [], isPending, error } = useQuery(gameQueryOptions.featuredByIds(gameIds));
const { userId, favoriteIds, toggleFavorite } = useFavorites();
const { userId, favoriteIds, toggleFavorite, isLoading, isUpdating, isUnavailable } = useFavorites();
const [carouselIndex, setCarouselIndex] = useState(0);
const [previousIndex, setPreviousIndex] = useState<number | null>(null);
const [direction, setDirection] = useState<Direction | null>(null);
Expand Down Expand Up @@ -137,6 +137,7 @@ export const FeaturedCarousel = ({gameIds}: FeaturedCarouselProps) => {
type="button"
aria-label={`${isFavorite ? 'Remove' : 'Add'} ${currentGameData.name} ${isFavorite ? 'from' : 'to'} favorites`}
aria-pressed={isFavorite}
disabled={isLoading || isUpdating || isUnavailable}
onClick={() => toggleFavorite(currentGameData.id)}
>
<Star size={20} weight={isFavorite ? 'fill' : 'regular'} aria-hidden="true" />
Expand Down
3 changes: 2 additions & 1 deletion T04-Project-2/frontend/src/components/GameCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type GameCardProps = {
}

export const GameCard = ({ id, title, image, releaseDate }: GameCardProps) => {
const { userId, favoriteIds, toggleFavorite } = useFavorites();
const { userId, favoriteIds, toggleFavorite, isLoading, isUpdating, isUnavailable } = useFavorites();
const databaseId = id && /^\d+$/.test(id) ? id : null;
const isFavorite = Boolean(databaseId && favoriteIds.includes(databaseId));

Expand Down Expand Up @@ -40,6 +40,7 @@ export const GameCard = ({ id, title, image, releaseDate }: GameCardProps) => {
type="button"
aria-label={`${isFavorite ? 'Remove' : 'Add'} ${title} ${isFavorite ? 'from' : 'to'} favorites`}
aria-pressed={isFavorite}
disabled={isLoading || isUpdating || isUnavailable}
onClick={() => toggleFavorite(databaseId)}
>
<Star size={20} weight={isFavorite ? 'fill' : 'regular'} aria-hidden="true" />
Expand Down
3 changes: 2 additions & 1 deletion T04-Project-2/frontend/src/components/GameDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type GameDetailPageProps = {

function GameDetailPage({ authenticatedUser }: GameDetailPageProps) {
const { gameId } = useParams();
const { userId, favoriteIds, toggleFavorite } = useFavorites();
const { userId, favoriteIds, toggleFavorite, isLoading, isUpdating, isUnavailable } = useFavorites();
const isDatabaseId = Boolean(gameId && /^\d+$/.test(gameId));
const { data: game, isPending, error } = useQuery({
...gameQueryOptions.byId(isDatabaseId ? gameId! : ''),
Expand Down Expand Up @@ -130,6 +130,7 @@ function GameDetailPage({ authenticatedUser }: GameDetailPageProps) {
title={`${isFavorite ? 'Remove' : 'Add'} ${game.name} ${isFavorite ? 'from' : 'to'} favorites`}
aria-label={`${isFavorite ? 'Remove' : 'Add'} ${game.name} ${isFavorite ? 'from' : 'to'} favorites`}
aria-pressed={isFavorite}
disabled={isLoading || isUpdating || isUnavailable}
onClick={() => toggleFavorite(game.id)}
>
<Star size={26} weight={isFavorite ? 'fill' : 'regular'} aria-hidden="true" />
Expand Down
28 changes: 28 additions & 0 deletions T04-Project-2/frontend/tests/components/FavoritesApi.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { fetchFavoriteIds, setFavorite } from '../../src/api/favorites'

const api = 'http://localhost:4000/graphql'
const server = setupServer()
beforeAll(() => { vi.stubEnv('VITE_API_URL', api); server.listen({ onUnhandledRequest: 'error' }) })
afterEach(() => { server.resetHandlers(); localStorage.clear() })
afterAll(() => { server.close(); vi.unstubAllEnvs() })

describe('favorites API', () => {
it('preserves legacy favorites when a migration request fails', async () => {
localStorage.setItem('gaminggoat:favorites:7', '["1"]')
server.use(http.post(api, async ({ request }) => {
const body = await request.json() as { query: string }
return body.query.includes('UserFavorites')
? HttpResponse.json({ data: { userFavorites: [] } })
: HttpResponse.json({ errors: [{ message: 'Save failed' }] })
}))
await expect(fetchFavoriteIds('7')).rejects.toThrow('Save failed')
expect(localStorage.getItem('gaminggoat:favorites:7')).toBe('["1"]')
})
it('reports a failed save to the caller', async () => {
server.use(http.post(api, () => new HttpResponse(null, { status: 503 })))
await expect(setFavorite('7', '1', true)).rejects.toThrow('HTTP error: 503')
})
})
Original file line number Diff line number Diff line change
@@ -1,40 +1,49 @@
import { afterEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { FavoritesProvider } from '../../src/components/FavoritesProvider'
import { useFavorites } from '../../src/components/useFavorites'
import * as api from '../../src/api/favorites'

vi.mock('../../src/api/favorites', () => ({ fetchFavoriteIds: vi.fn(), setFavorite: vi.fn() }))
function FavoritesControls() {
const { favoriteIds, toggleFavorite, storageError } = useFavorites()
return <><p>Favorites: {favoriteIds.join(', ') || 'none'}</p>{storageError && <p role="alert">{storageError}</p>}<button onClick={() => toggleFavorite('42')}>Toggle game</button><button onClick={() => toggleFavorite('invalid')}>Invalid game</button></>
const { favoriteIds, toggleFavorite, isLoading, isUpdating } = useFavorites()
return <><p>Favorites: {favoriteIds.join(', ') || 'none'}</p><button disabled={isLoading || isUpdating} onClick={() => toggleFavorite('42')}>Toggle game</button><button onClick={() => toggleFavorite('invalid')}>Invalid game</button></>
}

afterEach(() => localStorage.clear())
function show(userId: string | null) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return render(<QueryClientProvider client={client}><FavoritesProvider userId={userId}><FavoritesControls /></FavoritesProvider></QueryClientProvider>)
}
beforeEach(() => vi.resetAllMocks())
describe('favorites', () => {
it('adds and removes a favorite and persists the changes', async () => {
render(<FavoritesProvider userId="7"><FavoritesControls /></FavoritesProvider>)
it('loads, adds and removes favorites using the account API', async () => {
vi.mocked(api.fetchFavoriteIds).mockResolvedValueOnce([]).mockResolvedValueOnce(['42']).mockResolvedValueOnce([])
vi.mocked(api.setFavorite).mockResolvedValue(undefined)
show('7')
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Toggle game' })).toBeEnabled())
await userEvent.click(screen.getByRole('button', { name: 'Toggle game' }))
expect(screen.getByText('Favorites: 42')).toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('gaminggoat:favorites:7')!)).toEqual(['42'])
expect(await screen.findByText('Favorites: 42')).toBeInTheDocument()
expect(api.setFavorite).toHaveBeenCalledWith('7', '42', true)
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Toggle game' })).toBeEnabled())
await userEvent.click(screen.getByRole('button', { name: 'Toggle game' }))
expect(screen.getByText('Favorites: none')).toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('gaminggoat:favorites:7')!)).toEqual([])
expect(await screen.findByText('Favorites: none')).toBeInTheDocument()
expect(api.setFavorite).toHaveBeenCalledWith('7', '42', false)
})
it('restores saved favorites and ignores invalid IDs', async () => {
localStorage.setItem('gaminggoat:favorites:7', '["42"]')
render(<FavoritesProvider userId="7"><FavoritesControls /></FavoritesProvider>)
it('ignores invalid IDs and reports API errors', async () => {
vi.mocked(api.fetchFavoriteIds).mockResolvedValue(['42'])
vi.mocked(api.setFavorite).mockRejectedValue(new Error('Save failed'))
show('7')
await screen.findByText('Favorites: 42')
await userEvent.click(screen.getByRole('button', { name: 'Invalid game' }))
expect(screen.getByText('Favorites: 42')).toBeInTheDocument()
})
it('reports corrupt stored data', () => {
localStorage.setItem('gaminggoat:favorites:7', '["bad"]')
render(<FavoritesProvider userId="7"><FavoritesControls /></FavoritesProvider>)
expect(screen.getByRole('alert')).toHaveTextContent('invalid format')
expect(api.setFavorite).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: 'Toggle game' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Save failed')
})
it('does not save favorites when logged out', async () => {
render(<FavoritesProvider userId={null}><FavoritesControls /></FavoritesProvider>)
it('does not request favorites when logged out', async () => {
show(null)
await userEvent.click(screen.getByRole('button', { name: 'Toggle game' }))
expect(screen.getByText('Favorites: none')).toBeInTheDocument()
expect(localStorage.length).toBe(0)
expect(api.fetchFavoriteIds).not.toHaveBeenCalled()
expect(api.setFavorite).not.toHaveBeenCalled()
})
})
Loading