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
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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);
});
});
21 changes: 8 additions & 13 deletions app/(routes)/recommendations/RecommendationsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const userCountryRef = useRef<string | null | undefined>(undefined);
const nextButtonRef = useRef<HTMLButtonElement>(null);
const countryRequestRef = useRef<Promise<string> | null>(null);
const countryRequestRef = useRef<Promise<string | null> | null>(null);

function getResolvedUserCountry() {
if (userCountryRef.current) {
if (userCountryRef.current !== undefined) {
return Promise.resolve(userCountryRef.current);
}

Expand All @@ -97,7 +97,6 @@ export default function RecommendationsClient() {

useEffect(() => {
void getResolvedUserCountry();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useEffect(() => {
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 17 additions & 12 deletions lib/utils/geolocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {
Expand All @@ -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,
Expand All @@ -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();
});
});
8 changes: 3 additions & 5 deletions lib/utils/geolocation.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
export async function getUserCountry(): Promise<string | null> {
// Check local storage primarily
try {
const cached = localStorage.getItem(CACHE_KEY);
Expand Down Expand Up @@ -39,5 +37,5 @@ export async function getUserCountry(): Promise<string> {
console.error("Failed to determine user country:", error);
}

return FALLBACK_COUNTRY;
return null;
}
Loading