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
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"eslint": "^9.29.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"fast-check": "^4.10.2",
"identity-obj-proxy": "^3.0.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.5",
Expand Down
241 changes: 241 additions & 0 deletions app/src/tests/unit/calculator/ErrorPages.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { fireEvent, render, screen } from '@testing-library/react';
import fc from 'fast-check';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { countryIds } from '@/libs/countries';
import CountryError from '../../../../../calculator-app/src/app/[countryId]/(calculator)/error';
import WorkspaceError from '../../../../../calculator-app/src/app/[countryId]/(workspace)/error';
import GlobalError from '../../../../../calculator-app/src/app/error';
import { firstPathSegment, reportsHref } from '../../../../../calculator-app/src/app/reportsHref';

let mockPathname = '/';
let mockParams: Record<string, string> = {};

vi.mock('next/navigation', () => ({
usePathname: () => mockPathname,
useParams: () => mockParams,
}));

// The Reports link stays a plain <a>, so following it is a full page load
// that discards the client state that may have caused the error. A next/link
// would render with this marker.
vi.mock('next/link', () => ({
default: ({ children, ...props }: React.ComponentProps<'a'>) => (
<a data-next-link {...props}>
{children}
</a>
),
}));

const supportedReportsHrefs = countryIds.map((countryId) => `/${countryId}/reports`);

/** A path segment: any text without a slash. */
const pathSegment = fc.string({ unit: 'binary' }).map((segment) => segment.replaceAll('/', ''));

describe('reportsHref', () => {
test.each(countryIds)(
'given the supported country %s then it links to that country’s Reports page',
(countryId) => {
expect(reportsHref(countryId)).toBe(`/${countryId}/reports`);
}
);

test.each([undefined, null, 0, {}, 'xx', 'UK', ' uk', 'uk ', '', '../uk', 'constructor'])(
'given %j then it links to the US Reports page',
(value) => {
expect(reportsHref(value)).toBe('/us/reports');
}
);

test('given a list of segments, as a catch-all route has, then it links to the US Reports page', () => {
expect(reportsHref(['uk'])).toBe('/us/reports');
});

test('given any value then it links to a supported country’s Reports page', () => {
fc.assert(
fc.property(fc.anything(), (value) => {
expect(supportedReportsHrefs).toContain(reportsHref(value));
})
);
});

test('given any text that is not a supported country then it links to the US Reports page', () => {
fc.assert(
fc.property(fc.string({ unit: 'binary' }), (value) => {
fc.pre(!(countryIds as readonly string[]).includes(value));
expect(reportsHref(value)).toBe('/us/reports');
})
);
});
});

describe('firstPathSegment', () => {
test.each(['/', '', '//'])('given %j then there is no first segment', (pathname) => {
expect(firstPathSegment(pathname)).toBeUndefined();
});

test('given a path under a supported country then its first segment is that country', () => {
fc.assert(
fc.property(
fc.constantFrom(...countryIds),
fc.array(pathSegment),
fc.boolean(),
(countryId, rest, trailingSlash) => {
const pathname = `/${[countryId, ...rest].join('/')}${trailingSlash ? '/' : ''}`;
expect(firstPathSegment(pathname)).toBe(countryId);
}
)
);
});

// Next.js decodes route params once with decodeURIComponent, and usePathname()
// keeps the path's percent-encoding.
test.each([
['/%75k/reports', 'uk'],
['/%75%6B', 'uk'],
['/%55K/reports', 'UK'],
['/%2575k/reports', '%75k'],
['/uk%2Freports', 'uk/reports'],
['/%E0%A4%A/reports', '%E0%A4%A'],
])('given the path %s then its first segment is %j', (pathname, segment) => {
expect(firstPathSegment(pathname)).toBe(segment);
});

test('given a supported country with any of its letters percent-encoded then its first segment is that country', () => {
const encodings = (letter: string) => {
const hex = letter.charCodeAt(0).toString(16);
return [letter, `%${hex.toLowerCase()}`, `%${hex.toUpperCase()}`];
};
fc.assert(
fc.property(
fc
.constantFrom(...countryIds)
.chain((countryId) =>
fc.tuple(
fc.constant(countryId),
fc.tuple(...[...countryId].map((letter) => fc.constantFrom(...encodings(letter))))
)
),
fc.array(pathSegment),
([countryId, letters], rest) => {
const pathname = `/${[letters.join(''), ...rest].join('/')}`;
expect(firstPathSegment(pathname)).toBe(countryId);
}
)
);
});

test('given any path then the Reports link from it is a supported country’s', () => {
fc.assert(
fc.property(fc.array(pathSegment), (segments) => {
const pathname = `/${segments.join('/')}`;
expect(supportedReportsHrefs).toContain(reportsHref(firstPathSegment(pathname)));
})
);
});

test('given any mix of percent signs, hex digits and slashes then finding the first segment does not throw', () => {
const pathChar = fc.constantFrom('%', '/', '0', '5', '7', 'A', 'E', 'F', 'k', 'u');
fc.assert(
fc.property(
fc.array(pathChar).map((chars) => `/${chars.join('')}`),
(pathname) => {
expect(() => firstPathSegment(pathname)).not.toThrow();
}
)
);
});
});

describe('calculator error pages', () => {
const reset = vi.fn();
const error = new Error('Test error');

beforeEach(() => {
mockPathname = '/';
mockParams = {};
reset.mockClear();
// Each page logs the error it shows.
vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

function reportsLink() {
return screen.getByRole('link', { name: 'Go to reports' });
}

describe('root error page', () => {
test.each([
['/uk/reports/123', '/uk/reports'],
['/uk', '/uk/reports'],
['/ca/households', '/ca/reports'],
['/us/reports', '/us/reports'],
['/', '/us/reports'],
['/xx/reports', '/us/reports'],
['/UK/reports', '/us/reports'],
['/llms.txt', '/us/reports'],
['/%75k/reports', '/uk/reports'],
['/%E0%A4%A/reports', '/us/reports'],
])('given the path %s then it links to %s', (pathname, href) => {
mockPathname = pathname;
render(<GlobalError error={error} reset={reset} />);
expect(reportsLink()).toHaveAttribute('href', href);
});

test('given the Reports link then it is a plain link, not a client-side one', () => {
mockPathname = '/uk/reports';
render(<GlobalError error={error} reset={reset} />);
expect(reportsLink()).not.toHaveAttribute('data-next-link');
});

test('given Try again is clicked then the page resets', () => {
render(<GlobalError error={error} reset={reset} />);
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(reset).toHaveBeenCalledTimes(1);
});
});

// The workspace routes' error page re-exports the calculator routes' one.
describe.each([
['calculator', CountryError],
['workspace', WorkspaceError],
])('%s routes error page', (_group, ErrorPage) => {
test.each(countryIds)('given the country %s then it links to its Reports page', (countryId) => {
mockParams = { countryId };
render(<ErrorPage error={error} reset={reset} />);
expect(reportsLink()).toHaveAttribute('href', `/${countryId}/reports`);
});

test('given a country segment that is not a supported country then it links to the US Reports page', () => {
mockParams = { countryId: 'xx' };
render(<ErrorPage error={error} reset={reset} />);
expect(reportsLink()).toHaveAttribute('href', '/us/reports');
});

test('given no country in the route then it links to the US Reports page', () => {
render(<ErrorPage error={error} reset={reset} />);
expect(reportsLink()).toHaveAttribute('href', '/us/reports');
});

test('given the route country then it is taken from the route, not the path', () => {
mockParams = { countryId: 'uk' };
mockPathname = '/us/reports';
render(<ErrorPage error={error} reset={reset} />);
expect(reportsLink()).toHaveAttribute('href', '/uk/reports');
});

test('given the Reports link then it is a plain link, not a client-side one', () => {
mockParams = { countryId: 'uk' };
render(<ErrorPage error={error} reset={reset} />);
expect(reportsLink()).not.toHaveAttribute('data-next-link');
});

test('given Try again is clicked then the page resets', () => {
render(<ErrorPage error={error} reset={reset} />);
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(reset).toHaveBeenCalledTimes(1);
});
});
});
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions calculator-app/src/app/[countryId]/(calculator)/error.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"use client";

import { useEffect } from "react";
import { useParams } from "next/navigation";
import { colors } from "@/designTokens";
import { reportsHref } from "../../reportsHref";

export default function CountryError({
error,
Expand All @@ -10,6 +12,9 @@ export default function CountryError({
error: Error & { digest?: string };
reset: () => void;
}) {
const { countryId } = useParams<{ countryId: string }>();
const reportsPath = reportsHref(countryId);

useEffect(() => {
console.error("[Calculator] Route error:", error);
}, [error]);
Expand All @@ -35,9 +40,8 @@ export default function CountryError({
</button>
{/* A full page load rather than a client-side navigation, so the
client state that may have caused the error is discarded. */}
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a
href="/us/reports"
href={reportsPath}
className="tw:px-4 tw:py-2 tw:text-sm tw:font-medium tw:border tw:rounded-md"
style={{
borderColor: colors.border.light,
Expand Down
9 changes: 7 additions & 2 deletions calculator-app/src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"use client";

import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { colors } from "@/designTokens";
import { firstPathSegment, reportsHref } from "./reportsHref";

export default function GlobalError({
error,
Expand All @@ -10,6 +12,10 @@ export default function GlobalError({
error: Error & { digest?: string };
reset: () => void;
}) {
// This page also shows for errors outside a country route, such as on `/`,
// so it takes the country from the path when the path has one.
const reportsPath = reportsHref(firstPathSegment(usePathname()));

useEffect(() => {
console.error("[Calculator] Global error:", error);
}, [error]);
Expand All @@ -35,9 +41,8 @@ export default function GlobalError({
</button>
{/* A full page load rather than a client-side navigation, so the
client state that may have caused the error is discarded. */}
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a
href="/us/reports"
href={reportsPath}
className="tw:px-4 tw:py-2 tw:text-sm tw:font-medium tw:border tw:rounded-md"
style={{
borderColor: colors.border.light,
Expand Down
32 changes: 32 additions & 0 deletions calculator-app/src/app/reportsHref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { countryIds, DEFAULT_COUNTRY } from "@/libs/countries";

/**
* The Reports page the error pages link to. `countryId` is the visitor's
* country when the route has one. A value that is not a supported country
* ID, or no value, links to the US Reports page.
*/
export function reportsHref(countryId: unknown): string {
const country = countryIds.find((id) => id === countryId) ?? DEFAULT_COUNTRY;
return `/${country}/reports`;
}

/**
* The first segment of a URL path, which is the country on country routes:
* `"uk"` for `/uk/reports`. Undefined for `/`.
*
* The segment is decoded, as Next.js decodes route params: `usePathname()`
* gives `/%75k/reports` for a request to that path, while the route's
* `countryId` is `"uk"`.
*/
export function firstPathSegment(pathname: string): string | undefined {
const segment = pathname.split("/").filter(Boolean)[0];
if (segment === undefined) {
return undefined;
}
try {
return decodeURIComponent(segment);
} catch {
// Malformed percent-encoding, which Next.js rejects as a route param.
return segment;
}
}
Loading