From 239fc50beaec3665f7c6efdf0fa34f9c174cc45e Mon Sep 17 00:00:00 2001 From: Harold Torres Date: Sat, 4 Apr 2026 00:05:45 +1100 Subject: [PATCH] feat: enhance movie recommendations with user country-based watch providers - Implemented fetching of user country using api.country.is and caching in local storage. - Updated RecommendationsClient to retrieve and display streaming watch providers based on the user's country. - Modified searchMoviePoster to return both poster URL and movie ID. - Enhanced integration tests to cover new functionality and ensure proper handling of user country data. - Updated README and diagrams to reflect changes in the recommendations flow. --- README.md | 13 +- ...RecommendationsClient.integration.test.tsx | 734 +++++++++++++++++- .../recommendations/RecommendationsClient.tsx | 255 +++++- app/(routes)/recommendations/page.test.tsx | 20 +- docs/diagrams.md | 9 +- jest.setup.js | 7 + lib/services/tmdb.test.ts | 102 ++- lib/services/tmdb.ts | 60 +- lib/utils/geolocation.test.ts | 121 +++ lib/utils/geolocation.ts | 43 + tests/support/movie-test-fixtures.ts | 13 +- 11 files changed, 1314 insertions(+), 63 deletions(-) create mode 100644 lib/utils/geolocation.test.ts create mode 100644 lib/utils/geolocation.ts diff --git a/README.md b/README.md index dcdf12e..d7a6a09 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ sequenceDiagram participant Supabase as Supabase
(pgvector) participant LLM as LLM
(Gemini / OpenRouter) participant TMDB as TMDB API + participant API_Country as API Country
(api.country.is) User->>Browser: Submit movie preferences
(last participant) Browser->>RecAPI: POST participantsData + timeAvailable @@ -135,10 +136,14 @@ sequenceDiagram loop For each recommended movie Browser->>TMDB: searchMoviePoster(title) - TMDB-->>Browser: poster URL + TMDB-->>Browser: poster URL + movie ID + Browser->>API_Country: fetch user country + API_Country-->>Browser: AU (cached) + Browser->>TMDB: getMovieWatchProviders(id, country) + TMDB-->>Browser: watch providers list end - Browser-->>User: Recommendations carousel
with posters + Browser-->>User: Recommendations carousel
with posters & watch providers ``` @@ -168,13 +173,13 @@ The movie corpus lives in `public/constants/movies.txt` and is chunked and embed The matched movie content is split into individual entries and formatted as a "Movie List Context". This context, together with the original participant preferences, is sent to the LLM. We call **Google Gemini 2.5 Flash** (primary) via the Vercel AI SDK `streamObject` function to rank and filter the candidates. If Google is unavailable or its daily quota is exhausted (HTTP 429/403), the request automatically cascades through a series of **OpenRouter** fallbacks: **MiniMax M2.5**, **Llama 3.3 70B**, and finally **openrouter/free** (dynamic auto-router). -Quota errors trigger individualized circuit breakers: Google drops subsequent requests for **24 hours**, whereas transient OpenRouter drops bypass that specific model for just **5 minutes** before retrying. +Quota errors trigger individualised circuit breakers: Google drops subsequent requests for **24 hours**, whereas transient OpenRouter drops bypass that specific model for just **5 minutes** before retrying. A structured system prompt paired with a **Zod** schema (`movieRecommendationSchema`) instructs the model to return a stream of between 1 and 10 movies as a structured object, filtered by time constraints, era preference, mood, and genre fit. The server pipes this stream continuously back to the Next.js client, allowing the UI to display recommendations progressively as they are generated. ### 5. Fallback and display -If the LLM response cannot be parsed as valid JSON, a **heuristic fallback** (`lib/utils/recommendations.ts`) extracts movie titles, years, and synopses directly from the raw vector-match text. Movie posters are fetched from the **TMDB API** and displayed in a carousel. +If the LLM response cannot be parsed as valid JSON, a **heuristic fallback** (`lib/utils/recommendations.ts`) extracts movie titles, years, and synopses directly from the raw vector-match text. Movie posters and location-based streaming watch providers (provided by JustWatch) are fetched from the **TMDB API** and displayed in a carousel. The user's country is determined via `api.country.is` to localise the streaming providers shown. ## Tech Stack diff --git a/app/(routes)/recommendations/RecommendationsClient.integration.test.tsx b/app/(routes)/recommendations/RecommendationsClient.integration.test.tsx index 267bbff..7771272 100644 --- a/app/(routes)/recommendations/RecommendationsClient.integration.test.tsx +++ b/app/(routes)/recommendations/RecommendationsClient.integration.test.tsx @@ -18,6 +18,24 @@ jest.mock("next/navigation", () => ({ const mockReplace = jest.fn(); const originalFetch = global.fetch; +// Mock IntersectionObserver +const mockObserve = jest.fn(); +const mockDisconnect = jest.fn(); +let intersectionCallback: (entries: IntersectionObserverEntry[]) => void; + +global.IntersectionObserver = jest.fn((callback) => { + intersectionCallback = callback; + return { + observe: mockObserve, + disconnect: mockDisconnect, + unobserve: jest.fn(), + takeRecords: jest.fn(), + root: null, + rootMargin: "", + thresholds: [], + }; +}) as unknown as jest.Mock; + const seededParticipantsData = [ { favouriteMovie: "Inception", @@ -75,6 +93,7 @@ function getRequestUrl(input: RequestInfo | URL): string { describe("RecommendationsClient integration", () => { beforeEach(() => { jest.clearAllMocks(); + window.localStorage.clear(); (useRouter as jest.Mock).mockReturnValue({ replace: mockReplace }); }); @@ -94,12 +113,37 @@ describe("RecommendationsClient integration", () => { }); } - if (url.startsWith("https://api.themoviedb.org/")) { + if (url.includes("/search/movie")) { return Promise.resolve( createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) ); } + if (url.includes("/watch/providers")) { + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + AU: { + link: "https://example.com", + flatrate: [ + { + logo_path: "/au.jpg", + provider_id: 1, + provider_name: "Test", + display_priority: 1, + }, + ], + }, + }, + }) + ); + } + + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); }) as typeof fetch; @@ -134,12 +178,37 @@ describe("RecommendationsClient integration", () => { return Promise.resolve(createRecommendationStreamResponse()); } - if (url.startsWith("https://api.themoviedb.org/")) { + if (url.includes("/search/movie")) { return Promise.resolve( createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) ); } + if (url.includes("/watch/providers")) { + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + AU: { + link: "https://example.com", + flatrate: [ + { + logo_path: "/au.jpg", + provider_id: 1, + provider_name: "Test", + display_priority: 1, + }, + ], + }, + }, + }) + ); + } + + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); }) as typeof fetch; @@ -163,6 +232,10 @@ describe("RecommendationsClient integration", () => { return Promise.resolve(createRecommendationStreamResponse(recommendationFixtures.empty)); } + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); }) as typeof fetch; @@ -188,6 +261,10 @@ describe("RecommendationsClient integration", () => { ); } + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); }) as typeof fetch; @@ -208,6 +285,10 @@ describe("RecommendationsClient integration", () => { return Promise.reject(new Error("Failed to fetch")); } + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); }) as typeof fetch; @@ -226,12 +307,37 @@ describe("RecommendationsClient integration", () => { return Promise.resolve(createRecommendationStreamResponse()); } - if (url.startsWith("https://api.themoviedb.org/")) { + if (url.includes("/search/movie")) { return Promise.resolve( createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) ); } + if (url.includes("/watch/providers")) { + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + AU: { + link: "https://example.com", + flatrate: [ + { + logo_path: "/au.jpg", + provider_id: 1, + provider_name: "Test", + display_priority: 1, + }, + ], + }, + }, + }) + ); + } + + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); }) as typeof fetch; @@ -253,4 +359,626 @@ describe("RecommendationsClient integration", () => { expect(screen.getByTestId("session-state")).toHaveTextContent('"timeAvailable":""'); }); + + it("prefetches the next movie poster when the Next Movie button is in view", async () => { + const fetchUrls: string[] = []; + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + fetchUrls.push(url); + + if (url === "/api/recommendations") { + return Promise.resolve(createRecommendationStreamResponse()); + } + + if (url.includes("/search/movie")) { + return Promise.resolve( + createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) + ); + } + + if (url.includes("/watch/providers")) { + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + AU: { + link: "https://example.com", + flatrate: [ + { + logo_path: "/au.jpg", + provider_id: 1, + provider_name: "Test", + display_priority: 1, + }, + ], + }, + }, + }) + ); + } + + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + // Wait for the first movie to render + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" }) + ).toBeInTheDocument(); + }); + + // Simulate the Next Movie button becoming visible + act(() => { + intersectionCallback([ + { isIntersecting: true, target: {} as Element } as IntersectionObserverEntry, + ]); + }); + + // Verify that the second movie (Paddington 2 from fixtures) is being fetched + await waitFor(() => { + const searchForPaddington = fetchUrls.some((url) => url.includes("Paddington%202")); + expect(searchForPaddington).toBe(true); + }); + + // Simulate the Next Movie button becoming visible again — fetchControllersRef deduplicates + // concurrent requests, so even if the observer fires before the cache is written no second + // network call is actually made. + act(() => { + intersectionCallback([ + { isIntersecting: true, target: {} as Element } as IntersectionObserverEntry, + ]); + }); + + // At least one search for Paddington 2 must have been issued (prefetch branch hit) + expect(fetchUrls.filter((url) => url.includes("Paddington%202")).length).toBeGreaterThanOrEqual( + 1 + ); + }); + + it("does not prefetch when the Next Movie button is not intersecting", async () => { + const fetchUrls: string[] = []; + + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + fetchUrls.push(url); + + if (url === "/api/recommendations") { + return Promise.resolve(createRecommendationStreamResponse()); + } + if (url.includes("/search/movie")) { + return Promise.resolve( + createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) + ); + } + if (url.includes("/watch/providers")) { + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + AU: { + link: "https://example.com", + flatrate: [ + { + logo_path: "/au.jpg", + provider_id: 1, + provider_name: "Test", + display_priority: 1, + }, + ], + }, + }, + }) + ); + } + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" }) + ).toBeInTheDocument(); + }); + + act(() => { + intersectionCallback([ + { isIntersecting: false, target: {} as Element } as IntersectionObserverEntry, + ]); + }); + + await act(async () => {}); + + expect(fetchUrls.some((url) => url.includes("Paddington%202"))).toBe(false); + }); + + it("does not prefetch when only one movie is available", async () => { + const fetchUrls: string[] = []; + + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + fetchUrls.push(url); + + if (url === "/api/recommendations") { + return Promise.resolve( + createRecommendationStreamResponse(recommendationFixtures.singleMovie) + ); + } + if (url.includes("/search/movie")) { + return Promise.resolve( + createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) + ); + } + if (url.includes("/watch/providers")) { + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + AU: { + link: "https://example.com", + flatrate: [ + { + logo_path: "/au.jpg", + provider_id: 1, + provider_name: "Test", + display_priority: 1, + }, + ], + }, + }, + }) + ); + } + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" }) + ).toBeInTheDocument(); + }); + + act(() => { + intersectionCallback([ + { isIntersecting: true, target: {} as Element } as IntersectionObserverEntry, + ]); + }); + + await act(async () => {}); + + expect(fetchUrls.some((url) => url.includes("Paddington%202"))).toBe(false); + }); + + it("does not re-fetch the next movie poster once it is already cached", async () => { + const fetchUrls: string[] = []; + + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + fetchUrls.push(url); + + if (url === "/api/recommendations") { + return Promise.resolve(createRecommendationStreamResponse()); + } + if (url.includes("/search/movie")) { + if (url.includes("Paddington%202")) { + return Promise.resolve(createJsonResponse(tmdbFixtures.postersByQuery["Paddington 2"])); + } + return Promise.resolve( + createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) + ); + } + if (url.includes("/watch/providers")) { + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + AU: { + link: "https://example.com", + flatrate: [ + { + logo_path: "/au.jpg", + provider_id: 1, + provider_name: "Test", + display_priority: 1, + }, + ], + }, + }, + }) + ); + } + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + // Wait for first movie + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" }) + ).toBeInTheDocument(); + }); + + // Trigger background prefetch of Paddington 2 + act(() => { + intersectionCallback([ + { isIntersecting: true, target: {} as Element } as IntersectionObserverEntry, + ]); + }); + + // Wait for the prefetch fetch to be issued + await waitFor(() => { + expect(fetchUrls.some((url) => url.includes("Paddington%202"))).toBe(true); + }); + + // Navigate to Paddington 2 (cache hit — no second fetch) + fireEvent.click(screen.getByRole("button", { name: "Next Movie" })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Paddington 2 (2017)" })).toBeInTheDocument(); + }); + + // Navigate back to Mad Max + fireEvent.click(screen.getByRole("button", { name: "Next Movie" })); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" }) + ).toBeInTheDocument(); + }); + + const countBefore = fetchUrls.filter((url) => url.includes("Paddington%202")).length; + + // Fire IO — posterUrls["Paddington 2"] is now defined → cache guard skips fetch + act(() => { + intersectionCallback([ + { isIntersecting: true, target: {} as Element } as IntersectionObserverEntry, + ]); + }); + + await act(async () => {}); + + expect(fetchUrls.filter((url) => url.includes("Paddington%202")).length).toBe(countBefore); + }); + + it("scrolls to the top when Next Movie is clicked", async () => { + const scrollSpy = jest.spyOn(window, "scrollTo").mockImplementation(() => {}); + + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + if (url === "/api/recommendations") { + return Promise.resolve(createRecommendationStreamResponse()); + } + if (url.includes("/search/movie")) { + return Promise.resolve( + createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) + ); + } + if (url.includes("/watch/providers")) { + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + AU: { + link: "https://example.com", + flatrate: [ + { + logo_path: "/au.jpg", + provider_id: 1, + provider_name: "Test", + display_priority: 1, + }, + ], + }, + }, + }) + ); + } + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" }) + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Next Movie" })); + + expect(scrollSpy).toHaveBeenCalledWith({ top: 0, behavior: "smooth" }); + + scrollSpy.mockRestore(); + }); + + it("waits for geolocation before fetching watch providers", async () => { + const fetchUrls: string[] = []; + let resolveCountryResponse: ((response: Response) => void) | undefined; + + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + fetchUrls.push(url); + + if (url === "/api/recommendations") { + return Promise.resolve(createRecommendationStreamResponse()); + } + if (url.includes("/search/movie")) { + return Promise.resolve( + createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) + ); + } + if (url.startsWith("https://api.country.is")) { + return new Promise((resolve) => { + resolveCountryResponse = resolve; + }); + } + if (url.includes("/watch/providers")) { + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + FR: { + link: "https://fr.example.com", + flatrate: [ + { + logo_path: "/fr.jpg", + provider_id: 33, + provider_name: "France Provider", + display_priority: 1, + }, + ], + }, + }, + }) + ); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" }) + ).toBeInTheDocument(); + }); + + expect(fetchUrls.some((url) => url.includes("/watch/providers"))).toBe(false); + + await act(async () => { + resolveCountryResponse?.(createJsonResponse({ country: "FR" })); + }); + + expect(await screen.findByRole("img", { name: "France Provider" })).toBeInTheDocument(); + expect(fetchUrls.some((url) => url.includes("/watch/providers"))).toBe(true); + }); + + it("keeps the poster visible when watch provider lookup fails", async () => { + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + + if (url === "/api/recommendations") { + return Promise.resolve(createRecommendationStreamResponse()); + } + if (url.includes("/search/movie")) { + return Promise.resolve( + createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) + ); + } + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + if (url.includes("/watch/providers")) { + return Promise.reject(new Error("Provider API unavailable")); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + jest.spyOn(console, "error").mockImplementation(() => {}); + + renderRecommendations(); + + expect( + await screen.findByAltText("Mad Max: Fury Road", undefined, { timeout: 5000 }) + ).toBeInTheDocument(); + expect(screen.queryByText("Watch Providers")).not.toBeInTheDocument(); + }); + + it("does not let background prefetch clear the active poster loading state", async () => { + let resolveMadMaxPoster: ((value: Response) => void) | undefined; + + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + + if (url === "/api/recommendations") { + return Promise.resolve(createRecommendationStreamResponse()); + } + if (url.includes("Mad%20Max%3A%20Fury%20Road")) { + return new Promise((resolve) => { + resolveMadMaxPoster = resolve; + }); + } + if (url.includes("Paddington%202")) { + return Promise.resolve(createJsonResponse(tmdbFixtures.postersByQuery["Paddington 2"])); + } + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + if (url.includes("/watch/providers")) { + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + AU: { + link: "https://example.com", + flatrate: [ + { + logo_path: "/au.jpg", + provider_id: 1, + provider_name: "Test", + display_priority: 1, + }, + ], + }, + }, + }) + ); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Mad Max: Fury Road (2015)" }) + ).toBeInTheDocument(); + expect(screen.getByTestId("poster-loading")).toBeInTheDocument(); + }); + + act(() => { + intersectionCallback([ + { isIntersecting: true, target: {} as Element } as IntersectionObserverEntry, + ]); + }); + + await waitFor(() => { + expect(screen.getByTestId("poster-loading")).toBeInTheDocument(); + }); + + await act(async () => { + resolveMadMaxPoster?.(createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"])); + }); + + expect(await screen.findByAltText("Mad Max: Fury Road")).toBeInTheDocument(); + }); + + it("renders free and ad-supported providers", async () => { + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + + if (url === "/api/recommendations") { + return Promise.resolve(createRecommendationStreamResponse()); + } + if (url.includes("/search/movie")) { + return Promise.resolve( + createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) + ); + } + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "AU" })); + } + if (url.includes("/watch/providers")) { + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + AU: { + link: "https://example.com", + free: [ + { + logo_path: "/free.jpg", + provider_id: 7, + provider_name: "Free Provider", + display_priority: 1, + }, + ], + ads: [ + { + logo_path: "/ads.jpg", + provider_id: 8, + provider_name: "Ads Provider", + display_priority: 2, + }, + ], + }, + }, + }) + ); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + expect(await screen.findByRole("img", { name: "Free Provider" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Ads Provider" })).toBeInTheDocument(); + expect(screen.queryByText("No providers available.")).not.toBeInTheDocument(); + }); + + it("falls back to AU providers when current country has no data", async () => { + const fetchUrls: string[] = []; + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = getRequestUrl(input); + fetchUrls.push(url); + + if (url === "/api/recommendations") { + return Promise.resolve(createRecommendationStreamResponse()); + } + if (url.includes("/search/movie")) { + return Promise.resolve( + createJsonResponse(tmdbFixtures.postersByQuery["Mad Max: Fury Road"]) + ); + } + if (url.startsWith("https://api.country.is")) { + return Promise.resolve(createJsonResponse({ country: "FR" })); // User in France + } + if (url.includes("/watch/providers")) { + // Return results that have AU but NOT FR, to trigger the fallback + return Promise.resolve( + createJsonResponse({ + id: 12345, + results: { + AU: { + link: "https://au.toy", + flatrate: [ + { + logo_path: "/au.jpg", + provider_name: "AU Provider", + provider_id: 1, + display_priority: 1, + }, + ], + }, + }, + }) + ); + } + return Promise.reject(new Error(`Unhandled: ${url}`)); + }) as typeof fetch; + + renderRecommendations(); + + expect( + await screen.findByRole("img", { name: "AU Provider" }, { timeout: 5000 }) + ).toBeInTheDocument(); + + const providerLinks = fetchUrls.filter((u) => u.includes("/watch/providers")); + expect(providerLinks.length).toBeGreaterThanOrEqual(2); // One for FR, one for AU fallback + }); }); diff --git a/app/(routes)/recommendations/RecommendationsClient.tsx b/app/(routes)/recommendations/RecommendationsClient.tsx index 404e148..229b59b 100644 --- a/app/(routes)/recommendations/RecommendationsClient.tsx +++ b/app/(routes)/recommendations/RecommendationsClient.tsx @@ -4,27 +4,35 @@ import { useMovieContext } from "@/contexts/MovieContext"; import { useRouter } from "next/navigation"; import { useEffect, useReducer, useRef } from "react"; import Image from "next/image"; -import { searchMoviePoster } from "@/lib/services/tmdb"; +import { searchMoviePoster, getMovieWatchProviders, WatchProvidersData } from "@/lib/services/tmdb"; import { experimental_useObject as useObject } from "@ai-sdk/react"; import { movieRecommendationSchema } from "@/types/api"; +import { getUserCountry } from "@/lib/utils/geolocation"; type RecommendationsState = { currentIndex: number; posterUrls: Record; - isLoadingPoster: boolean; + watchProviders: Record; + loadingPosters: Record; }; const initialRecommendationsState: RecommendationsState = { currentIndex: 0, posterUrls: {}, - isLoadingPoster: true, + watchProviders: {}, + loadingPosters: {}, }; type RecommendationsAction = | { type: "NEXT"; totalMovies: number } | { type: "POSTER_CACHE_HIT" } - | { type: "POSTER_FETCH_START" } - | { type: "POSTER_FETCH_SUCCESS"; name: string; url: string } + | { type: "POSTER_FETCH_START"; name: string } + | { + type: "POSTER_FETCH_SUCCESS"; + name: string; + url: string; + providers: WatchProvidersData | null; + } | { type: "POSTER_FETCH_ERROR"; name: string }; function recommendationsViewReducer( @@ -38,20 +46,25 @@ function recommendationsViewReducer( currentIndex: state.currentIndex === action.totalMovies - 1 ? 0 : state.currentIndex + 1, }; case "POSTER_CACHE_HIT": - return { ...state, isLoadingPoster: false }; + return state; case "POSTER_FETCH_START": - return { ...state, isLoadingPoster: true }; + return { + ...state, + loadingPosters: { ...state.loadingPosters, [action.name]: true }, + }; case "POSTER_FETCH_SUCCESS": return { ...state, - isLoadingPoster: false, posterUrls: { ...state.posterUrls, [action.name]: action.url }, + watchProviders: { ...state.watchProviders, [action.name]: action.providers }, + loadingPosters: { ...state.loadingPosters, [action.name]: false }, }; case "POSTER_FETCH_ERROR": return { ...state, - isLoadingPoster: false, posterUrls: { ...state.posterUrls, [action.name]: "" }, + watchProviders: { ...state.watchProviders, [action.name]: null }, + loadingPosters: { ...state.loadingPosters, [action.name]: false }, }; } } @@ -60,9 +73,32 @@ export default function RecommendationsClient() { const { participantsData, timeAvailable, resetMovieSession } = useMovieContext(); const router = useRouter(); const [state, dispatch] = useReducer(recommendationsViewReducer, initialRecommendationsState); - const { currentIndex, posterUrls, isLoadingPoster } = state; + const { currentIndex, posterUrls, watchProviders, loadingPosters } = state; const hasSubmittedRef = useRef(false); const wasStoppedRef = useRef(false); + const userCountryRef = useRef(null); + const nextButtonRef = useRef(null); + const countryRequestRef = useRef | null>(null); + + function getResolvedUserCountry() { + if (userCountryRef.current) { + return Promise.resolve(userCountryRef.current); + } + + if (!countryRequestRef.current) { + countryRequestRef.current = getUserCountry().then((country) => { + userCountryRef.current = country; + return country; + }); + } + + return countryRequestRef.current; + } + + useEffect(() => { + void getResolvedUserCountry(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { if (participantsData.length === 0) { @@ -91,7 +127,7 @@ export default function RecommendationsClient() { }; }, [stop]); - const recommendedMovies = object?.recommendedMovies || []; + const recommendedMovies = object?.recommendedMovies ?? []; const currentMovie = recommendedMovies[currentIndex]; const currentMovieName = currentMovie?.name?.trim() || ""; const currentMovieReleaseYear = currentMovie?.releaseYear?.trim() || ""; @@ -105,6 +141,77 @@ export default function RecommendationsClient() { !error && recommendedMovies.length === 0; + const fetchControllersRef = useRef<{ [key: string]: AbortController }>({}); + + useEffect(() => { + const controllers = fetchControllersRef.current; + return () => { + Object.values(controllers).forEach((c) => c.abort()); + }; + }, []); + + async function fetchMovieData(movieName: string, isBackground: boolean) { + if (!movieName || posterUrls[movieName] !== undefined || fetchControllersRef.current[movieName]) + return; + + const controller = new AbortController(); + fetchControllersRef.current[movieName] = controller; + + if (!isBackground) { + dispatch({ type: "POSTER_FETCH_START", name: movieName }); + } + + try { + const result = await searchMoviePoster(movieName); + if (controller.signal.aborted) return; + + if (!result) { + if (!isBackground) { + dispatch({ type: "POSTER_FETCH_ERROR", name: movieName }); + } + return; + } + + let providers: WatchProvidersData | null = null; + try { + const userCountry = await getResolvedUserCountry(); + if (controller.signal.aborted) return; + + const fetchedProviders = await getMovieWatchProviders(result.id, userCountry); + if (controller.signal.aborted) return; + + if (fetchedProviders) { + providers = fetchedProviders; + } else if (userCountry !== "AU") { + const fallbackProviders = await getMovieWatchProviders(result.id, "AU"); + if (controller.signal.aborted) return; + if (fallbackProviders) { + providers = fallbackProviders; + } + } + } catch (providersError) { + console.error("Error fetching watch providers:", providersError); + } + + if (!controller.signal.aborted) { + dispatch({ + type: "POSTER_FETCH_SUCCESS", + name: movieName, + url: result.posterUrl, + providers, + }); + } + } catch (error: unknown) { + if (error instanceof Error && error.name === "AbortError") return; + console.error("Error fetching movie data:", error); + if (!controller.signal.aborted && !isBackground) { + dispatch({ type: "POSTER_FETCH_ERROR", name: movieName }); + } + } finally { + delete fetchControllersRef.current[movieName]; + } + } + useEffect(() => { if (!hasRenderableCurrentMovie) return; @@ -113,35 +220,53 @@ export default function RecommendationsClient() { return; } - let cancelled = false; - dispatch({ type: "POSTER_FETCH_START" }); - void searchMoviePoster(currentMovieName) - .then((url) => { - if (!cancelled) { - dispatch({ - type: "POSTER_FETCH_SUCCESS", - name: currentMovieName, - url: url || "", - }); - } - }) - .catch((error) => { - console.error("Error fetching poster:", error); - if (!cancelled) { - dispatch({ type: "POSTER_FETCH_ERROR", name: currentMovieName }); + void fetchMovieData(currentMovieName, false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentMovieName, hasRenderableCurrentMovie, posterUrls]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && recommendedMovies.length > 1) { + const nextIndex = (currentIndex + 1) % recommendedMovies.length; + const nextMovie = recommendedMovies[nextIndex]; + const nextMovieName = nextMovie?.name?.trim() || ""; + + if (nextMovieName && posterUrls[nextMovieName] === undefined) { + void fetchMovieData(nextMovieName, true); + } } - }); + }, + { threshold: 0.1 } + ); - return () => { - cancelled = true; - }; - }, [currentMovieName, hasRenderableCurrentMovie, posterUrls]); + if (nextButtonRef.current) { + observer.observe(nextButtonRef.current); + } + + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentIndex, recommendedMovies, posterUrls]); const handleNextMovie = () => { dispatch({ type: "NEXT", totalMovies: recommendedMovies.length }); + window.scrollTo({ top: 0, behavior: "smooth" }); }; const currentPosterUrl = currentMovieName ? posterUrls[currentMovieName] : ""; + const currentProviders = currentMovieName ? watchProviders[currentMovieName] : null; + const isLoadingPoster = currentMovieName ? (loadingPosters[currentMovieName] ?? false) : false; + + // Deduplicate and combine all providers for display + const allProviders = currentProviders + ? [ + ...(currentProviders.flatrate || []), + ...(currentProviders.free || []), + ...(currentProviders.ads || []), + ...(currentProviders.rent || []), + ...(currentProviders.buy || []), + ].filter((v, i, a) => a.findIndex((t) => t.provider_id === v.provider_id) === i) + : []; return ( <> @@ -231,16 +356,66 @@ export default function RecommendationsClient() { )} -
+ {currentProviders && ( +
+

Watch Providers

+ {allProviders.length > 0 ? ( +
+ {allProviders.map((provider) => ( +
+ {provider.provider_name} +
+ ))} +
+ ) : ( +

No providers available.

+ )} + {currentProviders.link && ( + + )} +
+ + Streaming availability data provided by JustWatch + +
+
+ )} + +
Movie {currentIndex + 1} of {recommendedMovies.length}
- + {recommendedMovies.length > 1 && ( + + )} )}
diff --git a/app/(routes)/recommendations/page.test.tsx b/app/(routes)/recommendations/page.test.tsx index b872881..efe29e8 100644 --- a/app/(routes)/recommendations/page.test.tsx +++ b/app/(routes)/recommendations/page.test.tsx @@ -1,10 +1,11 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { useRouter } from "next/navigation"; import { useMovieContext } from "@/contexts/MovieContext"; -import { searchMoviePoster } from "@/lib/services/tmdb"; +import { searchMoviePoster, getMovieWatchProviders } from "@/lib/services/tmdb"; import Recommendations from "./RecommendationsClient"; import { metadata } from "./page"; import { experimental_useObject } from "@ai-sdk/react"; +import { getUserCountry } from "@/lib/utils/geolocation"; const originalNodeEnv = process.env.NODE_ENV; @@ -27,6 +28,11 @@ jest.mock("next/image", () => ({ jest.mock("@/lib/services/tmdb", () => ({ searchMoviePoster: jest.fn(), + getMovieWatchProviders: jest.fn(), +})); + +jest.mock("@/lib/utils/geolocation", () => ({ + getUserCountry: jest.fn(), })); jest.mock("@/contexts/MovieContext", () => ({ @@ -67,7 +73,12 @@ describe("Recommendations Component", () => { timeAvailable: "2 hours", resetMovieSession: mockResetMovieSession, }); - (searchMoviePoster as jest.Mock).mockResolvedValue("http://example.com/poster.jpg"); + (getUserCountry as jest.Mock).mockResolvedValue("AU"); + (searchMoviePoster as jest.Mock).mockResolvedValue({ + posterUrl: "http://example.com/poster.jpg", + id: 12345, + }); + (getMovieWatchProviders as jest.Mock).mockResolvedValue(undefined); (experimental_useObject as jest.Mock).mockReturnValue({ object: { recommendedMovies: [ @@ -247,7 +258,10 @@ describe("Recommendations Component", () => { clear: mockClear, stop: mockStop, }); - (searchMoviePoster as jest.Mock).mockResolvedValue("http://example.com/poster.jpg"); + (searchMoviePoster as jest.Mock).mockResolvedValue({ + posterUrl: "http://example.com/poster.jpg", + id: 12345, + }); render(); diff --git a/docs/diagrams.md b/docs/diagrams.md index 15290d1..5f69ae8 100644 --- a/docs/diagrams.md +++ b/docs/diagrams.md @@ -75,6 +75,7 @@ sequenceDiagram participant Supabase as Supabase
(pgvector) participant LLM as LLM
(Gemini / OpenRouter) participant TMDB as TMDB API + participant API_Country as API Country
(api.country.is) User->>Browser: Submit movie preferences
(last participant) Browser->>RecAPI: POST participantsData + timeAvailable @@ -106,10 +107,14 @@ sequenceDiagram loop For each recommended movie Browser->>TMDB: searchMoviePoster(title) - TMDB-->>Browser: poster URL + TMDB-->>Browser: poster URL + movie ID + Browser->>API_Country: fetch user country + API_Country-->>Browser: AU (cached) + Browser->>TMDB: getMovieWatchProviders(id, country) + TMDB-->>Browser: watch providers list end - Browser-->>User: Recommendations carousel
with posters + Browser-->>User: Recommendations carousel
with posters & watch providers ``` --- diff --git a/jest.setup.js b/jest.setup.js index b1eee13..45a94e4 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -15,4 +15,11 @@ Object.assign(global, { TextEncoder, TransformStream, WritableStream, + scrollTo: jest.fn(), + IntersectionObserver: jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + takeRecords: jest.fn(), + })), }); diff --git a/lib/services/tmdb.test.ts b/lib/services/tmdb.test.ts index c0ebf43..a5ff7fc 100644 --- a/lib/services/tmdb.test.ts +++ b/lib/services/tmdb.test.ts @@ -1,21 +1,24 @@ -import { searchMoviePoster } from "./tmdb"; +import { searchMoviePoster, getMovieWatchProviders } from "./tmdb"; describe("searchMoviePoster", () => { beforeEach(() => { jest.clearAllMocks(); }); - it("returns the poster image URL on a successful fetch", async () => { + it("returns the poster image URL and ID on a successful fetch", async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({ - results: [{ poster_path: "/abc123.jpg" }], + results: [{ id: 12345, poster_path: "/abc123.jpg" }], }), }); const result = await searchMoviePoster("Inception"); - expect(result).toBe("https://image.tmdb.org/t/p/w342/abc123.jpg"); + expect(result).toStrictEqual({ + posterUrl: "https://image.tmdb.org/t/p/w342/abc123.jpg", + id: 12345, + }); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining("Inception"), expect.objectContaining({ method: "GET" }) @@ -58,6 +61,17 @@ describe("searchMoviePoster", () => { await expect(searchMoviePoster("Inception")).resolves.toBeUndefined(); }); + it("returns undefined when the first result has an empty poster path", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + results: [{ id: 12345, poster_path: "" }], + }), + }); + + await expect(searchMoviePoster("Inception")).resolves.toBeUndefined(); + }); + it("returns undefined and logs an error when TMDb responds with a non-ok status", async () => { global.fetch = jest.fn().mockResolvedValue({ ok: false, @@ -71,3 +85,83 @@ describe("searchMoviePoster", () => { consoleSpy.mockRestore(); }); }); + +describe("getMovieWatchProviders", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns watch providers for a given country code", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + id: 12345, + results: { + AU: { + link: "https://example.com/au", + flatrate: [ + { + logo_path: "/au.jpg", + provider_id: 8, + provider_name: "Netflix", + display_priority: 1, + }, + ], + }, + }, + }), + }); + + const result = await getMovieWatchProviders(12345, "AU"); + + expect(result).toStrictEqual({ + link: "https://example.com/au", + flatrate: [ + { logo_path: "/au.jpg", provider_id: 8, provider_name: "Netflix", display_priority: 1 }, + ], + }); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/3/movie/12345/watch/providers"), + expect.objectContaining({ method: "GET" }) + ); + }); + + it("returns undefined if the country code is not found in results", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + id: 12345, + results: { US: { link: "..." } }, + }), + }); + + const result = await getMovieWatchProviders(12345, "AU"); + expect(result).toBeUndefined(); + }); + + it("returns undefined and logs error when fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network Error")); + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const result = await getMovieWatchProviders(12345, "AU"); + + expect(result).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it("returns undefined and logs error when TMDb responds with a non-ok status", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 403, + json: jest.fn(), + }); + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const result = await getMovieWatchProviders(12345, "AU"); + + expect(result).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); +}); diff --git a/lib/services/tmdb.ts b/lib/services/tmdb.ts index e52bc6c..dba6fb7 100644 --- a/lib/services/tmdb.ts +++ b/lib/services/tmdb.ts @@ -1,5 +1,23 @@ -/** Fetches the poster URL for the first TMDb search hit. Requires `NEXT_PUBLIC_TMBD_ACCESS_TOKEN`. Returns nothing on network/API failure. */ -export const searchMoviePoster = async (movieTitle: string) => { +export type WatchProvider = { + logo_path: string; + provider_id: number; + provider_name: string; + display_priority: number; +}; + +export type WatchProvidersData = { + link: string; + flatrate?: WatchProvider[]; + free?: WatchProvider[]; + ads?: WatchProvider[]; + rent?: WatchProvider[]; + buy?: WatchProvider[]; +}; + +/** Fetches the poster URL and movie ID for the first TMDb search hit. Requires `NEXT_PUBLIC_TMBD_ACCESS_TOKEN`. Returns nothing on network/API failure. */ +export const searchMoviePoster = async ( + movieTitle: string +): Promise<{ posterUrl: string; id: number } | undefined> => { const options = { method: "GET", headers: { @@ -17,14 +35,46 @@ export const searchMoviePoster = async (movieTitle: string) => { throw new Error(`TMDb request failed with status ${response.status}`); } const data = await response.json(); - const posterPath = data.results?.[0]?.poster_path; + const movie = data.results?.[0]; + const posterPath = movie?.poster_path; + const movieId = movie?.id; - if (typeof posterPath !== "string" || posterPath.length === 0) { + if (typeof posterPath !== "string" || posterPath.length === 0 || typeof movieId !== "number") { return; } const imageUrl = `https://image.tmdb.org/t/p/w342${posterPath}`; - return imageUrl; + return { posterUrl: imageUrl, id: movieId }; + } catch (err) { + console.error(err); + } +}; + +/** Fetches watch providers for a specific movie ID in a specific country. */ +export const getMovieWatchProviders = async ( + movieId: number, + countryCode: string +): Promise => { + const options = { + method: "GET", + headers: { + accept: "application/json", + Authorization: `Bearer ${process.env.NEXT_PUBLIC_TMBD_ACCESS_TOKEN}`, + }, + }; + + try { + const response = await fetch( + `https://api.themoviedb.org/3/movie/${movieId}/watch/providers`, + options + ); + if (!response.ok) { + throw new Error(`TMDb request failed with status ${response.status}`); + } + const data = await response.json(); + + // Returns watch providers for the given country, if they exist + return data.results?.[countryCode]; } catch (err) { console.error(err); } diff --git a/lib/utils/geolocation.test.ts b/lib/utils/geolocation.test.ts new file mode 100644 index 0000000..740e1c1 --- /dev/null +++ b/lib/utils/geolocation.test.ts @@ -0,0 +1,121 @@ +import { getUserCountry } from "./geolocation"; + +const CACHE_KEY = "user_country_code"; +const originalFetch = global.fetch; +let mockStorage: Record = {}; + +beforeEach(() => { + mockStorage = {}; + Object.defineProperty(window, "localStorage", { + value: { + getItem: jest.fn((key) => mockStorage[key] || null), + setItem: jest.fn((key, value) => { + mockStorage[key] = value.toString(); + }), + clear: jest.fn(() => { + mockStorage = {}; + }), + }, + writable: true, + }); +}); + +afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); +}); + +describe("getUserCountry", () => { + it("should return the country code from localStorage if available", async () => { + mockStorage[CACHE_KEY] = "NZ"; + global.fetch = jest.fn(); // Ensure fetch is not called + + const result = await getUserCountry(); + expect(result).toBe("NZ"); + expect(global.fetch).not.toHaveBeenCalled(); + expect(window.localStorage.getItem).toHaveBeenCalledWith(CACHE_KEY); + }); + + it("should fetch country from api.country.is and save to localStorage on success", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ ip: "1.1.1.1", country: "US" }), + }); + + const result = await getUserCountry(); + + expect(result).toBe("US"); + expect(global.fetch).toHaveBeenCalledWith("https://api.country.is/"); + expect(window.localStorage.setItem).toHaveBeenCalledWith(CACHE_KEY, "US"); + }); + + it("should fallback to AU on API failure", async () => { + global.fetch = jest.fn(() => Promise.reject(new Error("Network error"))); + // Spying console.error to keep logs clean + jest.spyOn(console, "error").mockImplementation(() => {}); + + const result = await getUserCountry(); + expect(result).toBe("AU"); + }); + + it("should ignore localStorage errors and fallback to AU if API fails", async () => { + Object.defineProperty(window, "localStorage", { + value: { + getItem: () => { + throw new Error("Access denied"); + }, + setItem: () => { + throw new Error("Access denied"); + }, + }, + writable: true, + }); + global.fetch = jest.fn(() => Promise.reject(new Error("Network error"))); + jest.spyOn(console, "error").mockImplementation(() => {}); + + const result = await getUserCountry(); + expect(result).toBe("AU"); + }); + + it("should fallback to AU if API response format is unsupported", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ not_country: "US" }), + }); + + const result = await getUserCountry(); + expect(result).toBe("AU"); + }); + + it("should fallback to AU when the country value is not a string", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ country: 61 }), + }); + + const result = await getUserCountry(); + expect(result).toBe("AU"); + }); + + it("should fallback to AU when the country code is not exactly two characters", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ country: "AUS" }), + }); + + const result = await getUserCountry(); + expect(result).toBe("AU"); + }); + + it("should fallback to AU when the country API returns a non-ok status", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + json: jest.fn(), + }); + jest.spyOn(console, "error").mockImplementation(() => {}); + + const result = await getUserCountry(); + expect(result).toBe("AU"); + }); +}); diff --git a/lib/utils/geolocation.ts b/lib/utils/geolocation.ts new file mode 100644 index 0000000..533786a --- /dev/null +++ b/lib/utils/geolocation.ts @@ -0,0 +1,43 @@ +const CACHE_KEY = "user_country_code"; +const FALLBACK_COUNTRY = "AU"; + +/** + * Fetches the user's country code based on their IP address using api.country.is. + * Values are cached in local storage to prevent rate limits and repeated network calls. + * Returns `"AU"` on failure or as a predefined fallback. + */ +export async function getUserCountry(): Promise { + // Check local storage primarily + try { + const cached = localStorage.getItem(CACHE_KEY); + if (cached) { + return cached; + } + } catch { + // Ignore localStorage errors (e.g., incognito mode) + } + + try { + const response = await fetch("https://api.country.is/"); + if (!response.ok) { + throw new Error(`Country API responded with ${response.status}`); + } + const data = await response.json(); + + if (data && data.country && typeof data.country === "string" && data.country.length === 2) { + const countryCode = data.country.toUpperCase(); + + try { + localStorage.setItem(CACHE_KEY, countryCode); + } catch { + // Ignore localStorage errors + } + + return countryCode; + } + } catch (error) { + console.error("Failed to determine user country:", error); + } + + return FALLBACK_COUNTRY; +} diff --git a/tests/support/movie-test-fixtures.ts b/tests/support/movie-test-fixtures.ts index 908bf08..103dae9 100644 --- a/tests/support/movie-test-fixtures.ts +++ b/tests/support/movie-test-fixtures.ts @@ -13,6 +13,15 @@ export const recommendationFixtures = { }, ], }, + singleMovie: { + recommendedMovies: [ + { + name: "Mad Max: Fury Road", + releaseYear: "2015", + synopsis: "A relentless desert chase with furious stunts and a huge heart.", + }, + ], + }, empty: { recommendedMovies: [], }, @@ -21,9 +30,9 @@ export const recommendationFixtures = { export type RecommendationFixture = (typeof recommendationFixtures)[keyof typeof recommendationFixtures]; -export function createTmdbSearchResponse(posterPath?: string) { +export function createTmdbSearchResponse(posterPath?: string, id = 12345) { return { - results: posterPath ? [{ poster_path: posterPath }] : [], + results: posterPath ? [{ id, poster_path: posterPath }] : [], }; }