diff --git a/app/(routes)/recommendations/RecommendationsClient.integration.test.tsx b/app/(routes)/recommendations/RecommendationsClient.integration.test.tsx index 7771272..b4c313b 100644 --- a/app/(routes)/recommendations/RecommendationsClient.integration.test.tsx +++ b/app/(routes)/recommendations/RecommendationsClient.integration.test.tsx @@ -807,6 +807,56 @@ describe("RecommendationsClient integration", () => { expect(screen.queryByText("Watch Providers")).not.toBeInTheDocument(); }); + it("keeps the poster visible when geolocation 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.reject(new Error("Country lookup unavailable")); + } + if (url.includes("/watch/providers")) { + 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 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(); + expect(screen.queryByRole("img", { name: "AU Provider" })).not.toBeInTheDocument(); + }); + it("does not let background prefetch clear the active poster loading state", async () => { let resolveMadMaxPoster: ((value: Response) => void) | undefined; @@ -931,7 +981,7 @@ describe("RecommendationsClient integration", () => { expect(screen.queryByText("No providers available.")).not.toBeInTheDocument(); }); - it("falls back to AU providers when current country has no data", async () => { + it("does not fall 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); @@ -949,7 +999,7 @@ describe("RecommendationsClient integration", () => { 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 results that have AU but not FR to verify we do not cross over to AU. return Promise.resolve( createJsonResponse({ id: 12345, @@ -975,10 +1025,14 @@ describe("RecommendationsClient integration", () => { renderRecommendations(); expect( - await screen.findByRole("img", { name: "AU Provider" }, { timeout: 5000 }) + await screen.findByAltText("Mad Max: Fury Road", undefined, { timeout: 5000 }) ).toBeInTheDocument(); + expect(screen.queryByText("Watch Providers")).not.toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "AU Provider" })).not.toBeInTheDocument(); - const providerLinks = fetchUrls.filter((u) => u.includes("/watch/providers")); - expect(providerLinks.length).toBeGreaterThanOrEqual(2); // One for FR, one for AU fallback + const countryRequests = fetchUrls.filter((u) => u.startsWith("https://api.country.is")); + const providerRequests = fetchUrls.filter((u) => u.includes("/watch/providers")); + expect(countryRequests).toHaveLength(1); + expect(providerRequests).toHaveLength(1); }); }); diff --git a/app/(routes)/recommendations/RecommendationsClient.tsx b/app/(routes)/recommendations/RecommendationsClient.tsx index 229b59b..fd13dec 100644 --- a/app/(routes)/recommendations/RecommendationsClient.tsx +++ b/app/(routes)/recommendations/RecommendationsClient.tsx @@ -76,12 +76,12 @@ export default function RecommendationsClient() { const { currentIndex, posterUrls, watchProviders, loadingPosters } = state; const hasSubmittedRef = useRef(false); const wasStoppedRef = useRef(false); - const userCountryRef = useRef(null); + const userCountryRef = useRef(undefined); const nextButtonRef = useRef(null); - const countryRequestRef = useRef | null>(null); + const countryRequestRef = useRef | null>(null); function getResolvedUserCountry() { - if (userCountryRef.current) { + if (userCountryRef.current !== undefined) { return Promise.resolve(userCountryRef.current); } @@ -97,7 +97,6 @@ export default function RecommendationsClient() { useEffect(() => { void getResolvedUserCountry(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -177,16 +176,12 @@ export default function RecommendationsClient() { 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 (userCountry) { + const fetchedProviders = await getMovieWatchProviders(result.id, userCountry); if (controller.signal.aborted) return; - if (fallbackProviders) { - providers = fallbackProviders; + + if (fetchedProviders) { + providers = fetchedProviders; } } } catch (providersError) { diff --git a/lib/utils/geolocation.test.ts b/lib/utils/geolocation.test.ts index 740e1c1..2772b02 100644 --- a/lib/utils/geolocation.test.ts +++ b/lib/utils/geolocation.test.ts @@ -49,16 +49,17 @@ describe("getUserCountry", () => { expect(window.localStorage.setItem).toHaveBeenCalledWith(CACHE_KEY, "US"); }); - it("should fallback to AU on API failure", async () => { + it("should return null 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"); + expect(result).toBeNull(); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); }); - it("should ignore localStorage errors and fallback to AU if API fails", async () => { + it("should ignore localStorage errors and return null if API fails", async () => { Object.defineProperty(window, "localStorage", { value: { getItem: () => { @@ -74,40 +75,43 @@ describe("getUserCountry", () => { jest.spyOn(console, "error").mockImplementation(() => {}); const result = await getUserCountry(); - expect(result).toBe("AU"); + expect(result).toBeNull(); }); - it("should fallback to AU if API response format is unsupported", async () => { + it("should return null 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"); + expect(result).toBeNull(); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); }); - it("should fallback to AU when the country value is not a string", async () => { + it("should return null 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"); + expect(result).toBeNull(); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); }); - it("should fallback to AU when the country code is not exactly two characters", async () => { + it("should return null 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"); + expect(result).toBeNull(); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); }); - it("should fallback to AU when the country API returns a non-ok status", async () => { + it("should return null when the country API returns a non-ok status", async () => { global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500, @@ -116,6 +120,7 @@ describe("getUserCountry", () => { jest.spyOn(console, "error").mockImplementation(() => {}); const result = await getUserCountry(); - expect(result).toBe("AU"); + expect(result).toBeNull(); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); }); }); diff --git a/lib/utils/geolocation.ts b/lib/utils/geolocation.ts index 533786a..8778340 100644 --- a/lib/utils/geolocation.ts +++ b/lib/utils/geolocation.ts @@ -1,12 +1,10 @@ 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. + * Returns `null` when the user's country cannot be determined. */ -export async function getUserCountry(): Promise { +export async function getUserCountry(): Promise { // Check local storage primarily try { const cached = localStorage.getItem(CACHE_KEY); @@ -39,5 +37,5 @@ export async function getUserCountry(): Promise { console.error("Failed to determine user country:", error); } - return FALLBACK_COUNTRY; + return null; }