diff --git a/.github/workflows/preview-pr.yml b/.github/workflows/preview-pr.yml index 6d80a398..8bb402cc 100644 --- a/.github/workflows/preview-pr.yml +++ b/.github/workflows/preview-pr.yml @@ -86,7 +86,7 @@ jobs: VERCEL_PROJECT_ID: ${{ env.VERCEL_PROJECT_ID }} - name: Cypress test - uses: cypress-io/github-action@v6 + uses: cypress-io/github-action@v7 with: start: yarn e2e:setup command: yarn e2e:run diff --git a/messages/en.json b/messages/en.json index 8990e62d..832e30ef 100644 --- a/messages/en.json +++ b/messages/en.json @@ -125,7 +125,9 @@ "metricsAdminOnly": "Metrics - Admin Only", "languageSelect": "Language select", "transitFeedsRedirectTitle": "You've been redirected from TransitFeeds", - "transitFeedsRedirectBody": "This page now lives on MobilityDatabase.org, where you'll find the most up-to-date transit data." + "transitFeedsRedirectBody": "This page now lives on MobilityDatabase.org, where you'll find the most up-to-date transit data.", + "locations": "Locations", + "feedsMenu": "Feeds menu" }, "emailVerification": { "loadingTitle": "Verifying your email", @@ -1254,7 +1256,8 @@ "contactUs": "Contact Us", "shareFeedback": "Share Feedback", "privacyPolicy": "Privacy Policy", - "termsAndConditions": "Terms and Conditions" + "termsAndConditions": "Terms and Conditions", + "locations": "Locations" }, "aria": { "github": "GitHub", @@ -1262,5 +1265,40 @@ "linkedin": "LinkedIn", "logo": "Mobility Database logo" } + }, + "locations": { + "root": "Locations", + "title": "Feeds by location", + "rootIntro": "Browse open GTFS Schedule, GTFS Realtime and GBFS feeds by location, from continents down to cities.", + "nodeIntro": "{total, plural, =0 {No feeds are} one {# feed is} other {# feeds are}} located in {place}.", + "feedCountsAria": "Feed counts by data type", + "shortLabels": { + "gtfs": "GTFS", + "gtfs_rt": "GTFS-RT", + "gbfs": "GBFS" + }, + "childrenHeading": { + "continents": "Continents", + "countries": "Countries", + "places": "Places" + }, + "childFeedTotal": "{count, plural, one {# feed} other {# feeds}}", + "childrenSearchPlaceholder": "Search locations", + "childrenSearchAria": "Filter the locations below by name", + "childLoadingAria": "Loading {name}", + "childrenSearchResults": "{count, plural, =0 {No matching locations} one {# matching location} other {# matching locations}}", + "childrenSearchEmpty": "No location matches “{query}”.", + "feedsHeading": "Feeds in {place}", + "allFeedsHeading": "All feeds", + "resultsRange": "Showing {start}–{end} of {total} feeds", + "noFeeds": "No feeds are listed for this location yet.", + "paginationAria": "Feed list pages", + "meta": { + "rootTitle": "Transit Feeds by Location | Mobility Database", + "rootDescription": "Browse open GTFS Schedule, GTFS Realtime and GBFS transit and mobility feeds by location, from continents down to cities.", + "nodeTitle": "{place} Transit Feeds | Mobility Database", + "nodeTitlePaged": "{place} Transit Feeds, Page {page} | Mobility Database", + "nodeDescription": "Browse {total} open transit and mobility feeds located in {place}: {gtfs} GTFS Schedule, {gtfs_rt} GTFS Realtime and {gbfs} GBFS." + } } } diff --git a/messages/fr.json b/messages/fr.json index 81336666..28a7ebe6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -125,7 +125,9 @@ "metricsAdminOnly": "Métriques - Admin uniquement", "languageSelect": "Sélection de la langue", "transitFeedsRedirectTitle": "Vous avez été redirigé depuis TransitFeeds", - "transitFeedsRedirectBody": "Cette page se trouve désormais sur MobilityDatabase.org, où vous trouverez les données de transit les plus récentes." + "transitFeedsRedirectBody": "Cette page se trouve désormais sur MobilityDatabase.org, où vous trouverez les données de transit les plus récentes.", + "locations": "Lieux", + "feedsMenu": "Menu des flux" }, "emailVerification": { "loadingTitle": "Vérification de votre e-mail", @@ -1254,7 +1256,8 @@ "contactUs": "Contactez-nous", "shareFeedback": "Donner un avis", "privacyPolicy": "Politique de confidentialité", - "termsAndConditions": "Conditions d'utilisation" + "termsAndConditions": "Conditions d'utilisation", + "locations": "Lieux" }, "aria": { "github": "GitHub", @@ -1262,5 +1265,40 @@ "linkedin": "LinkedIn", "logo": "Logo Mobility Database" } + }, + "locations": { + "root": "Lieux", + "title": "Flux par lieu", + "rootIntro": "Parcourez les flux ouverts GTFS Schedule, GTFS Realtime et GBFS par lieu, des continents jusqu’aux villes.", + "nodeIntro": "{total, plural, =0 {Aucun flux n’est situé} one {# flux est situé} other {# flux sont situés}} dans : {place}.", + "feedCountsAria": "Nombre de flux par type de données", + "shortLabels": { + "gtfs": "GTFS", + "gtfs_rt": "GTFS-RT", + "gbfs": "GBFS" + }, + "childrenHeading": { + "continents": "Continents", + "countries": "Pays", + "places": "Lieux" + }, + "childFeedTotal": "{count, plural, one {# flux} other {# flux}}", + "childrenSearchPlaceholder": "Rechercher un lieu", + "childrenSearchAria": "Filtrer les lieux ci-dessous par nom", + "childLoadingAria": "Chargement de {name}", + "childrenSearchResults": "{count, plural, =0 {Aucun lieu correspondant} one {# lieu correspondant} other {# lieux correspondants}}", + "childrenSearchEmpty": "Aucun lieu ne correspond à « {query} ».", + "feedsHeading": "Flux dans : {place}", + "allFeedsHeading": "Tous les flux", + "resultsRange": "{start}–{end} sur {total} flux", + "noFeeds": "Aucun flux n’est encore répertorié pour ce lieu.", + "paginationAria": "Pages de la liste des flux", + "meta": { + "rootTitle": "Flux de transport par lieu | Mobility Database", + "rootDescription": "Parcourez les flux ouverts de transport et de mobilité GTFS Schedule, GTFS Realtime et GBFS par lieu, des continents jusqu’aux villes.", + "nodeTitle": "Flux de transport : {place} | Mobility Database", + "nodeTitlePaged": "Flux de transport : {place}, page {page} | Mobility Database", + "nodeDescription": "Parcourez {total} flux ouverts de transport et de mobilité situés dans : {place}. {gtfs} GTFS Schedule, {gtfs_rt} GTFS Realtime et {gbfs} GBFS." + } } } diff --git a/src/app/[locale]/locations/[[...location]]/loading.tsx b/src/app/[locale]/locations/[[...location]]/loading.tsx new file mode 100644 index 00000000..4f26a04e --- /dev/null +++ b/src/app/[locale]/locations/[[...location]]/loading.tsx @@ -0,0 +1,99 @@ +import { type ReactElement } from 'react'; +import { Box, Container, Skeleton } from '@mui/material'; +import SectionContainer from '../../../components/SectionContainer'; + +const CARD_COUNT = 8; +const ROW_COUNT = 8; + +/** + * Shown while a location page streams in, e.g. after clicking a child + * location; same layout as page.tsx so nothing jumps when it resolves. + * ref: https://nextjs.org/docs/app/api-reference/file-conventions/loading + */ +export default function Loading(): ReactElement { + return ( + + + {/* Back button and breadcrumb */} + + {/* Title and intro */} + + + {/* Feed count chips */} + + {[150, 140, 100].map((width) => ( + + ))} + + + {/* Child locations */} + + + {Array.from({ length: CARD_COUNT }, (_, index) => ( + + ))} + + + {/* Feeds table */} + + {Array.from({ length: ROW_COUNT }, (_, index) => ( + + ))} + + + ); +} diff --git a/src/app/[locale]/locations/[[...location]]/page.tsx b/src/app/[locale]/locations/[[...location]]/page.tsx new file mode 100644 index 00000000..f7b3fd63 --- /dev/null +++ b/src/app/[locale]/locations/[[...location]]/page.tsx @@ -0,0 +1,240 @@ +import { type ReactElement } from 'react'; +import { type Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { Box, Container, Typography } from '@mui/material'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import BreadcrumbNavigation, { + type Crumb, +} from '../../../components/BreadcrumbNavigation'; +import SectionContainer from '../../../components/SectionContainer'; +import FeedCountSummary from '../components/FeedCountSummary'; +import LocationChildrenGrid from '../components/LocationChildrenGrid'; +import LocationFeedsList from '../components/LocationFeedsList'; +import { + buildBreadcrumbSteps, + buildLocationHref, + buildPlaceName, + FEEDS_SECTION_ID, + getLocationName, + getPageCount, + getTotalFeedCount, + parseLocationSegments, + parsePageParam, +} from '../lib/location-directory'; +import { + fetchLocationPage, + type LocationPageData, +} from '../lib/location-directory-data'; + +const SITE_URL = 'https://mobilitydatabase.org'; + +interface PageProps { + params: Promise<{ locale: string; location?: string[] }>; + searchParams: Promise>; +} + +interface ParsedRequest { + locale: string; + /** URL segments: the location's API path slugs, continent first. */ + segments: string[]; + page: number; +} + +/** The request, or null when the URL or the page number is malformed. */ +async function parseRequest({ + params, + searchParams, +}: PageProps): Promise { + const [{ locale, location: segments }, { page: rawPage }] = await Promise.all( + [params, searchParams], + ); + const slugs = parseLocationSegments(segments); + const page = parsePageParam(rawPage); + if (slugs === null || page === null) return null; + return { locale, segments: slugs, page }; +} + +/** + * The page data, or null when the location does not exist or the page is + * past the last one. Deduped by React cache() between generateMetadata and + * the page. + */ +async function fetchPageData({ + segments, + page, +}: ParsedRequest): Promise { + const data = await fetchLocationPage(page, segments.join('/')); + if (data === null || page > getPageCount(data.feeds.total ?? 0)) { + return null; + } + return data; +} + +export async function generateMetadata(props: PageProps): Promise { + const request = await parseRequest(props); + if (request === null) return {}; + const [data, t] = await Promise.all([ + fetchPageData(request), + getTranslations({ locale: request.locale, namespace: 'locations.meta' }), + ]); + if (data === null) return {}; + + const { location } = data; + const { page } = request; + const place = buildPlaceName(location); + const counts = location?.feed_counts; + const title = + location === null + ? t('rootTitle') + : page > 1 + ? t('nodeTitlePaged', { place, page }) + : t('nodeTitle', { place }); + const description = + location === null + ? t('rootDescription') + : t('nodeDescription', { + place, + total: getTotalFeedCount(counts), + gtfs: counts?.gtfs ?? 0, + gtfs_rt: counts?.gtfs_rt ?? 0, + gbfs: counts?.gbfs ?? 0, + }); + // The URL is the location's API path. + const url = `${SITE_URL}${buildLocationHref(request.segments, page)}`; + + return { + title, + description, + alternates: { canonical: url }, + openGraph: { + title, + description, + url, + siteName: 'Mobility Database', + type: 'website', + }, + }; +} + +export default async function LocationsPage( + props: PageProps, +): Promise { + const request = await parseRequest(props); + if (request === null) notFound(); + const { locale, page, segments } = request; + setRequestLocale(locale); + + const [data, t, tCommon] = await Promise.all([ + fetchPageData(request), + getTranslations('locations'), + getTranslations('common'), + ]); + if (data === null) notFound(); + + const { location, children, feeds } = data; + const isRoot = location === null; + const total = feeds.total ?? 0; + const pageCount = getPageCount(total); + const place = buildPlaceName(location); + const steps = buildBreadcrumbSteps(location); + + const crumbs: Crumb[] = [ + { label: tCommon('feeds'), href: '/feeds' }, + { label: t('root'), href: isRoot ? undefined : buildLocationHref([]) }, + ...steps.map((step, index) => ({ + label: step.label, + href: + index === steps.length - 1 + ? undefined + : buildLocationHref(step.segments), + })), + ]; + // Without history to pop, back goes one level up the directory. + const backFallbackHref = isRoot + ? '/feeds' + : buildLocationHref(segments.slice(0, -1)); + const childrenHeading = isRoot + ? t('childrenHeading.continents') + : location.location_type === 'continent' + ? t('childrenHeading.countries') + : t('childrenHeading.places'); + + return ( + + + + + {isRoot ? t('title') : getLocationName(location)} + + + {isRoot + ? t('rootIntro') + : t('nodeIntro', { + total: getTotalFeedCount(location.feed_counts), + place, + })} + + + {/* No catalog-wide counts by data type at the root. */} + {isRoot ? null : ( + + )} + + {children.length > 0 ? ( + + + {childrenHeading}{' '} + + ({children.length}) + + + + + ) : null} + + + + {isRoot ? t('allFeedsHeading') : t('feedsHeading', { place })} + + + + + + ); +} diff --git a/src/app/[locale]/locations/components/FeedCountChips.tsx b/src/app/[locale]/locations/components/FeedCountChips.tsx new file mode 100644 index 00000000..9e673cd7 --- /dev/null +++ b/src/app/[locale]/locations/components/FeedCountChips.tsx @@ -0,0 +1,46 @@ +import { type ReactElement } from 'react'; +import { Box, Chip } from '@mui/material'; +import { type FeedCountDataType } from '../lib/location-directory'; + +export interface FeedCountChip { + dataType: FeedCountDataType; + count: number; + /** Already formatted, e.g. "42 GTFS Schedule". */ + label: string; +} + +interface Props { + chips: FeedCountChip[]; + size?: 'small' | 'medium'; + ariaLabel?: string; +} + +/** + * GTFS / GTFS-RT / GBFS counts as outlined chips. Hook-free, so it renders in + * both server and client components. + */ +export default function FeedCountChips({ + chips, + size = 'medium', + ariaLabel, +}: Props): ReactElement { + return ( + + {chips.map(({ dataType, count, label }) => ( + 0 ? 'primary' : 'default'} + sx={count > 0 ? { fontWeight: 700 } : { color: 'text.disabled' }} + /> + ))} + + ); +} diff --git a/src/app/[locale]/locations/components/FeedCountSummary.tsx b/src/app/[locale]/locations/components/FeedCountSummary.tsx new file mode 100644 index 00000000..55b90eab --- /dev/null +++ b/src/app/[locale]/locations/components/FeedCountSummary.tsx @@ -0,0 +1,44 @@ +import { type ReactElement } from 'react'; +import { getTranslations } from 'next-intl/server'; +import FeedCountChips from './FeedCountChips'; +import { + formatCount, + getFeedCountEntries, + type FeedCountDataType, + type LocationFeedCounts, +} from '../lib/location-directory'; + +interface Props { + counts: LocationFeedCounts | undefined; + locale: string; +} + +const LABEL_KEYS: Record = { + gtfs: 'gtfsSchedule', + gtfs_rt: 'gtfsRealtime', + gbfs: 'gbfs', +}; + +/** The node's feed totals split by GTFS Schedule / GTFS Realtime / GBFS. */ +export default async function FeedCountSummary({ + counts, + locale, +}: Props): Promise { + const [tCommon, t] = await Promise.all([ + getTranslations('common'), + getTranslations('locations'), + ]); + + return ( +
+ ({ + dataType, + count, + label: `${formatCount(count, locale)} ${tCommon(LABEL_KEYS[dataType])}`, + }))} + /> +
+ ); +} diff --git a/src/app/[locale]/locations/components/LinkPendingIndicator.tsx b/src/app/[locale]/locations/components/LinkPendingIndicator.tsx new file mode 100644 index 00000000..f1df17e4 --- /dev/null +++ b/src/app/[locale]/locations/components/LinkPendingIndicator.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { type ReactElement } from 'react'; +import { LinearProgress } from '@mui/material'; +import { keyframes } from '@mui/material/styles'; +// useLinkStatus is not re-exported by i18n/navigation; it reads the status of +// the nearest enclosing Link, which next-intl's Link renders under the hood. +import { useLinkStatus } from 'next/link'; + +// Fast navigations finish before the bar shows, so it never flashes. +const appearAfterDelay = keyframes` + to { opacity: 1; } +`; + +interface Props { + ariaLabel: string; +} + +/** + * Thin bar along the bottom of the enclosing Link while its navigation is + * pending. Must render inside a Link; the Link needs `position: relative`. + */ +export default function LinkPendingIndicator({ + ariaLabel, +}: Props): ReactElement | null { + const { pending } = useLinkStatus(); + if (!pending) return null; + return ( + + ); +} diff --git a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx new file mode 100644 index 00000000..e5eeb472 --- /dev/null +++ b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx @@ -0,0 +1,73 @@ +import { type ReactElement } from 'react'; +import { getTranslations } from 'next-intl/server'; +import { getEmojiFlag, type TCountryCode } from 'countries-list'; +import LocationChildrenList, { + type LocationChildItem, +} from './LocationChildrenList'; +import { + buildLocationHref, + formatCount, + getFeedCountEntries, + getLocationName, + getLocationSegments, + getTotalFeedCount, + sortByFeedCount, + type LocationResult, +} from '../lib/location-directory'; + +interface Props { + locations: LocationResult[]; + locale: string; +} + +function getFlag(location: LocationResult): string | undefined { + if ( + location.location_type !== 'country' || + location.country_code == undefined + ) + return undefined; + const flag = getEmojiFlag( + location.country_code.toUpperCase() as TCountryCode, + ); + return flag !== '' ? flag : undefined; +} + +/** + * Child locations, most feeds first. Everything that needs the server + * (flags, translations, number formatting, links) is resolved here so the + * client list only filters plain data. + */ +export default async function LocationChildrenGrid({ + locations, + locale, +}: Props): Promise { + const t = await getTranslations('locations'); + + const items: LocationChildItem[] = sortByFeedCount( + locations.map((location) => ({ + location, + name: getLocationName(location), + counts: location.feed_counts, + })), + ).map(({ location, name, counts }) => { + const href = buildLocationHref(getLocationSegments(location)); + return { + key: href, + name, + flag: getFlag(location), + href, + totalLabel: t.rich('childFeedTotal', { + count: getTotalFeedCount(counts), + b: (chunks) => {chunks}, + }), + counts: getFeedCountEntries(counts).map(({ dataType, count }) => ({ + dataType, + count, + formattedCount: formatCount(count, locale), + typeLabel: t(`shortLabels.${dataType}`), + })), + }; + }); + + return ; +} diff --git a/src/app/[locale]/locations/components/LocationChildrenList.tsx b/src/app/[locale]/locations/components/LocationChildrenList.tsx new file mode 100644 index 00000000..51b3fadd --- /dev/null +++ b/src/app/[locale]/locations/components/LocationChildrenList.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useMemo, useState, type ReactElement, type ReactNode } from 'react'; +import { + Box, + Card, + CardActionArea, + InputAdornment, + TextField, + Typography, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import { useTranslations } from 'next-intl'; +import { Link } from '../../../../i18n/navigation'; +import LinkPendingIndicator from './LinkPendingIndicator'; +import { + filterByName, + type FeedCountDataType, +} from '../lib/location-directory'; + +export interface LocationChildCount { + dataType: FeedCountDataType; + count: number; + /** Locale-formatted, rendered bold. */ + formattedCount: string; + /** e.g. "GTFS-RT". */ + typeLabel: string; +} + +export interface LocationChildItem { + /** Unique among siblings (the href). */ + key: string; + name: string; + /** Emoji flag, countries only. */ + flag?: string; + href: string; + /** e.g. "8 feeds", with the number in a . */ + totalLabel: ReactNode; + /** Plain-text counts; chips are kept for the page summary. */ + counts: LocationChildCount[]; +} + +// Bold, primary-colored numbers stand out from their secondary-colored labels. +const countNumberSx = { + '& strong': { fontWeight: 700, color: 'text.primary' }, +} as const; + +interface Props { + /** Already sorted, most feeds first. */ + items: LocationChildItem[]; +} + +/** Searchable, scrollable grid of child locations. */ +export default function LocationChildrenList({ items }: Props): ReactElement { + const t = useTranslations('locations'); + const [query, setQuery] = useState(''); + const visibleItems = useMemo( + () => filterByName(items, query), + [items, query], + ); + + return ( + <> + { + setQuery(event.target.value); + }} + placeholder={t('childrenSearchPlaceholder')} + slotProps={{ + htmlInput: { + 'aria-label': t('childrenSearchAria'), + 'aria-controls': 'location-children', + 'data-testid': 'location-children-search', + }, + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ + mb: 1, + maxWidth: { md: 480 }, + // White in light mode, like the cards below; the section is paper. + '& .MuiInputBase-root': { bgcolor: 'background.default' }, + }} + /> + + {query.trim() !== '' + ? t('childrenSearchResults', { count: visibleItems.length }) + : null} + + + {visibleItems.length > 0 ? ( + + {visibleItems.map((item) => ( +
  • + + + + + {item.flag != undefined + ? `${item.flag} ${item.name}` + : item.name} + + + {item.totalLabel} + + + + {item.counts.map( + ({ dataType, count, formattedCount, typeLabel }) => ( + 0 ? 'text.secondary' : 'text.disabled' + } + // Zero counts stay fully disabled. + sx={count > 0 ? countNumberSx : undefined} + > + {formattedCount} {typeLabel} + + ), + )} + + + + +
  • + ))} +
    + ) : ( + + {t('childrenSearchEmpty', { query: query.trim() })} + + )} +
    + + ); +} diff --git a/src/app/[locale]/locations/components/LocationFeedsList.tsx b/src/app/[locale]/locations/components/LocationFeedsList.tsx new file mode 100644 index 00000000..ae28dd48 --- /dev/null +++ b/src/app/[locale]/locations/components/LocationFeedsList.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { type ReactElement } from 'react'; +import { Box, Pagination, PaginationItem, Typography } from '@mui/material'; +import { useTranslations } from 'next-intl'; +import SearchTable from '../../../screens/Feeds/SearchTable'; +import { useSealOfReliabilityFilterAccess } from '../../../hooks/useSealOfReliabilityFilterAccess'; +import { type SearchFeedItem } from '../../../services/feeds/utils'; +import { Link } from '../../../../i18n/navigation'; +import { + buildLocationHref, + FEEDS_SECTION_ID, + getPageOffset, + LOCATION_FEEDS_PAGE_SIZE, +} from '../lib/location-directory'; + +interface Props { + feeds: SearchFeedItem[]; + total: number; + page: number; + pageCount: number; + /** URL segments of the current location, used to build the page links. */ + segments: string[]; +} + +/** + * The feeds of a directory location in the same compact table as the /feeds + * search (simple view), paginated with real links so every page is + * crawlable and reflected in the URL. + */ +export default function LocationFeedsList({ + feeds, + total, + page, + pageCount, + segments, +}: Props): ReactElement { + const t = useTranslations('locations'); + // Same flag the /feeds search uses to show the seal badge on rows. + const { isFeatureLive: isSealEnabled } = useSealOfReliabilityFilterAccess(); + + if (feeds.length === 0) { + return {t('noFeeds')}; + } + + const start = getPageOffset(page) + 1; + const end = Math.min(start + LOCATION_FEEDS_PAGE_SIZE - 1, total); + + return ( + <> + + {t('resultsRange', { start, end, total })} + + + + + {pageCount > 1 ? ( + ( + + )} + /> + ) : null} + + ); +} diff --git a/src/app/[locale]/locations/lib/location-directory-data.spec.ts b/src/app/[locale]/locations/lib/location-directory-data.spec.ts new file mode 100644 index 00000000..671863be --- /dev/null +++ b/src/app/[locale]/locations/lib/location-directory-data.spec.ts @@ -0,0 +1,308 @@ +/** + * @jest-environment node + */ + +import { ApiError } from '../../../services/feeds'; +import { fetchLocationPage } from './location-directory-data'; + +jest.mock('server-only', () => ({})); + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + cache: (fn: unknown) => fn, +})); + +// Behaves like unstable_cache: a resolved value is stored per key parts and +// arguments, a thrown error is not. Tests reset the store between them. +const mockCacheStore = new Map(); +jest.mock('next/cache', () => ({ + unstable_cache: ( + fn: (...args: A) => Promise, + keyParts: string[], + ): ((...args: A) => Promise) => { + return async (...args: A): Promise => { + const key = JSON.stringify([keyParts, args]); + if (mockCacheStore.has(key)) return mockCacheStore.get(key) as R; + const value = await fn(...args); + mockCacheStore.set(key, value); + return value; + }; + }, +})); + +const mockGetLocations = jest.fn(); +const mockGetLocationByPath = jest.fn(); +const mockGetLocationChildren = jest.fn(); +const mockSearchFeeds = jest.fn(); + +jest.mock('../../../services/feeds', () => ({ + ApiError: class ApiError extends Error { + readonly status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } + }, + getLocations: (...args: unknown[]) => mockGetLocations(...args), + getLocationByPath: (...args: unknown[]) => mockGetLocationByPath(...args), + getLocationChildren: (...args: unknown[]) => mockGetLocationChildren(...args), + searchFeeds: (...args: unknown[]) => mockSearchFeeds(...args), +})); + +jest.mock('../../../utils/auth-server', () => ({ + getGuestGcipIdToken: async () => 'guest-token', +})); + +const STATUSES = ['active', 'inactive', 'development', 'future']; +const MONTREAL_PATH = 'north-america/canada/quebec/montreal'; + +const northAmerica = { + location_id: -5, + location_type: 'continent', + name: 'North America', + slug: 'north-america', + path: 'north-america', + path_slugs: ['north-america'], + feed_counts: { total: 30 }, +}; +const montreal = { + location_id: 175905, + location_type: 'municipality', + name: 'Montréal', + slug: 'montreal', + path: MONTREAL_PATH, + path_slugs: ['north-america', 'canada', 'quebec', 'montreal'], + feed_counts: { total: 22 }, +}; +const feeds = { total: 22, results: [{ id: 'mdb-1' }] }; +const emptyFeeds = { total: 0, results: [] }; + +function child(index: number): typeof montreal { + return { + ...montreal, + location_id: index + 1, + slug: `child-${index + 1}`, + path: `${MONTREAL_PATH}/child-${index + 1}`, + }; +} + +/** The lookup answer: the location and the first page of its children. */ +function detail( + location: Record, + children: unknown[], + total: number = children.length, +): Record { + return { ...location, children: { total, results: children } }; +} + +function searchQueries(): Array> { + return mockSearchFeeds.mock.calls.map( + ([params]) => (params as { query: Record }).query, + ); +} + +describe('fetchLocationPage', () => { + beforeEach(() => { + mockCacheStore.clear(); + mockGetLocations.mockReset(); + mockGetLocationByPath.mockReset(); + mockGetLocationChildren.mockReset(); + mockSearchFeeds.mockReset(); + mockSearchFeeds.mockResolvedValue(feeds); + }); + + it('lists the continents with feeds and the newest feeds at the root', async () => { + mockGetLocations.mockResolvedValue({ total: 1, results: [northAmerica] }); + + const data = await fetchLocationPage(2); + + expect(data).toEqual({ location: null, children: [northAmerica], feeds }); + expect(mockGetLocations.mock.calls).toEqual([ + [ + { + location_type: 'continent', + has_feeds: true, + include: ['feed_counts'], + sort: 'feed_count', + limit: 100, + offset: 0, + }, + 'guest-token', + ], + ]); + expect(searchQueries()).toEqual([ + { status: STATUSES, limit: 20, offset: 20 }, + ]); + expect(mockGetLocationByPath).not.toHaveBeenCalled(); + }); + + it('looks a continent up by path, like any other location', async () => { + const canada = { ...montreal, location_type: 'country', slug: 'canada' }; + mockGetLocationByPath.mockResolvedValue(detail(northAmerica, [canada])); + + const data = await fetchLocationPage(1, 'north-america'); + + expect(data).toEqual({ location: northAmerica, children: [canada], feeds }); + expect(searchQueries()).toEqual([ + { + location_path: 'north-america', + sort: 'location', + status: STATUSES, + limit: 20, + offset: 0, + }, + ]); + expect(mockGetLocations).not.toHaveBeenCalled(); + }); + + it('fetches the location, its children and its feeds in parallel', async () => { + let resolveLookup: (value: unknown) => void = () => {}; + mockGetLocationByPath.mockReturnValue( + new Promise((resolve) => { + resolveLookup = resolve; + }), + ); + + const pending = fetchLocationPage(3, MONTREAL_PATH); + // The feeds are requested before the lookup answers. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(searchQueries()).toEqual([ + { + location_path: MONTREAL_PATH, + sort: 'location', + status: STATUSES, + limit: 20, + offset: 40, + }, + ]); + resolveLookup(detail(montreal, [child(0)])); + + await expect(pending).resolves.toEqual({ + location: montreal, + children: [child(0)], + feeds, + }); + expect(mockGetLocationByPath.mock.calls).toEqual([ + [ + { + path: MONTREAL_PATH, + include: ['feed_counts', 'children'], + has_feeds: true, + sort: 'feed_count', + limit: 100, + }, + 'guest-token', + ], + ]); + expect(mockGetLocationChildren).not.toHaveBeenCalled(); + }); + + it('pages through the children beyond the first 100', async () => { + const children = Array.from({ length: 230 }, (_, index) => child(index)); + mockGetLocationByPath.mockResolvedValue( + detail(montreal, children.slice(0, 100), 230), + ); + mockGetLocationChildren.mockImplementation( + async (_id: number, query: { offset: number; limit: number }) => ({ + total: 230, + results: children.slice(query.offset, query.offset + query.limit), + }), + ); + + const data = await fetchLocationPage(1, MONTREAL_PATH); + + expect(data?.children).toEqual(children); + expect(mockGetLocationChildren.mock.calls).toEqual([ + [ + 175905, + { + has_feeds: true, + include: ['feed_counts'], + sort: 'feed_count', + limit: 100, + offset: 100, + }, + 'guest-token', + ], + [ + 175905, + { + has_feeds: true, + include: ['feed_counts'], + sort: 'feed_count', + limit: 100, + offset: 200, + }, + 'guest-token', + ], + ]); + }); + + it('is null when the lookup is a 404, and never caches the miss', async () => { + mockSearchFeeds.mockResolvedValue(emptyFeeds); + mockGetLocationByPath.mockRejectedValueOnce(new ApiError('Not Found', 404)); + await expect(fetchLocationPage(1, MONTREAL_PATH)).resolves.toBeNull(); + // Nothing cached for the unknown path, not even its empty feed page. + expect(mockCacheStore.size).toBe(0); + + mockSearchFeeds.mockResolvedValue(feeds); + mockGetLocationByPath.mockResolvedValue(detail(montreal, [])); + await expect(fetchLocationPage(1, MONTREAL_PATH)).resolves.toMatchObject({ + location: montreal, + feeds, + }); + }); + + it('never caches an empty feed page', async () => { + mockGetLocationByPath.mockResolvedValue(detail(montreal, [])); + mockSearchFeeds.mockResolvedValue(emptyFeeds); + + await expect(fetchLocationPage(9, MONTREAL_PATH)).resolves.toMatchObject({ + feeds: emptyFeeds, + }); + await fetchLocationPage(9, MONTREAL_PATH); + expect(mockSearchFeeds).toHaveBeenCalledTimes(2); + expect(mockGetLocationByPath).toHaveBeenCalledTimes(1); + }); + + it('resolves a 400 or 404 to null without caching it', async () => { + mockGetLocations + .mockRejectedValueOnce(new ApiError('Bad Request', 400)) + .mockResolvedValue({ total: 1, results: [northAmerica] }); + + await expect(fetchLocationPage(1)).resolves.toBeNull(); + await expect(fetchLocationPage(1)).resolves.toMatchObject({ + children: [northAmerica], + }); + + mockSearchFeeds.mockReset(); + mockSearchFeeds + .mockRejectedValueOnce(new ApiError('Not Found', 404)) + .mockResolvedValueOnce(feeds); + await expect(fetchLocationPage(2)).resolves.toBeNull(); + await expect(fetchLocationPage(2)).resolves.toMatchObject({ feeds }); + }); + + it('caches a found location', async () => { + mockGetLocationByPath.mockResolvedValue(detail(montreal, [])); + + await fetchLocationPage(1, MONTREAL_PATH); + await fetchLocationPage(2, MONTREAL_PATH); + await fetchLocationPage(2, MONTREAL_PATH); + // The location once; one call per feed page. + expect(mockGetLocationByPath).toHaveBeenCalledTimes(1); + expect(mockSearchFeeds).toHaveBeenCalledTimes(2); + }); + + it('rethrows other API errors', async () => { + mockGetLocations.mockRejectedValueOnce(new ApiError('Server Error', 500)); + await expect(fetchLocationPage(1)).rejects.toThrow('Server Error'); + + mockGetLocationByPath.mockRejectedValueOnce( + new ApiError('Server Error', 500), + ); + await expect(fetchLocationPage(1, MONTREAL_PATH)).rejects.toThrow( + 'Server Error', + ); + }); +}); diff --git a/src/app/[locale]/locations/lib/location-directory-data.ts b/src/app/[locale]/locations/lib/location-directory-data.ts new file mode 100644 index 00000000..09ca4544 --- /dev/null +++ b/src/app/[locale]/locations/lib/location-directory-data.ts @@ -0,0 +1,257 @@ +/** + * Cacheable data fetching for the /locations directory. + * + * The directory is public, catalog-wide data, so these functions use the + * shared guest token and must NOT call cookies() or headers() directly or + * indirectly (same rule as the guest feed pages, see guest-feed-data.ts). + */ +import 'server-only'; +import { cache } from 'react'; +import { unstable_cache } from 'next/cache'; +import { getGuestGcipIdToken } from '../../../utils/auth-server'; +import { + ApiError, + getLocationByPath, + getLocationChildren, + getLocations, + searchFeeds, + type LocationChildrenParams, + type SearchFeedsResponse, +} from '../../../services/feeds'; +import { + fetchAllPages, + fetchRemainingPages, + getPageOffset, + LOCATION_FEEDS_PAGE_SIZE, + LOCATIONS_API_PAGE_SIZE, + type LocationResult, +} from './location-directory'; + +/** 6 hours; also flushed on demand with the existing `guest-feeds` tag. */ +export const LOCATION_DIRECTORY_REVALIDATE_SECONDS = 21600; +const CACHE_TAGS = ['location-directory', 'guest-feeds']; +const CACHE_OPTIONS = { + tags: CACHE_TAGS, + revalidate: LOCATION_DIRECTORY_REVALIDATE_SECONDS, +}; + +/** The statuses `feed_counts` counts, so a location lists as many feeds. */ +const DIRECTORY_FEED_STATUSES: Array< + 'active' | 'inactive' | 'development' | 'future' +> = ['active', 'inactive', 'development', 'future']; + +/** + * A URL that does not exist: a 400/404 from the API (an unknown path is a + * 404 from the lookup). Thrown inside the cached functions because + * unstable_cache never stores a thrown error: caching the miss would keep a + * location missing for hours after it starts to exist. + */ +class LocationNotFoundError extends Error {} + +async function requireData( + request: () => Promise, +): Promise { + try { + const data = await request(); + if (data == undefined) { + throw new Error('Empty response from the locations API'); + } + return data; + } catch (error) { + if ( + error instanceof ApiError && + (error.status === 400 || error.status === 404) + ) { + throw new LocationNotFoundError(error.message); + } + throw error; + } +} + +/** Resolves a missing location to null so the page can call notFound(). */ +async function nullWhenMissing( + request: () => Promise, +): Promise { + try { + return await request(); + } catch (error) { + if (error instanceof LocationNotFoundError) return null; + throw error; + } +} + +/** Children with feeds, most feeds first, with their counts. */ +function childrenQuery(): Required< + Pick +> { + return { has_feeds: true, include: ['feed_counts'], sort: 'feed_count' }; +} + +// unstable_cache keys on the key parts plus the call arguments, so the +// arguments are kept primitive. + +/** The continents with feeds, most feeds first (every page). */ +const cachedContinents = unstable_cache( + async (): Promise => { + const accessToken = await getGuestGcipIdToken(); + return await fetchAllPages( + async (offset, limit) => + await requireData( + async () => + await getLocations( + { ...childrenQuery(), location_type: 'continent', limit, offset }, + accessToken, + ), + ), + ); + }, + ['location-directory-continents'], + CACHE_OPTIONS, +); + +interface LocationWithChildren { + location: LocationResult; + /** Every direct child with feeds, most feeds first. */ + children: LocationResult[]; +} + +/** + * The location at `path` and its children with feeds: one lookup holds the + * location and the first 100 children, the remaining children pages are + * then fetched in parallel. Throws when no location has that path. + */ +const cachedLocation = unstable_cache( + async (path: string): Promise => { + const accessToken = await getGuestGcipIdToken(); + const { children: firstPage, ...location } = await requireData( + async () => + await getLocationByPath( + { + ...childrenQuery(), + path, + include: ['feed_counts', 'children'], + limit: LOCATIONS_API_PAGE_SIZE, + }, + accessToken, + ), + ); + const locationId = location.location_id; + const children = await fetchRemainingPages( + firstPage ?? {}, + async (offset, limit) => + await requireData( + async () => + await getLocationChildren( + locationId, + { ...childrenQuery(), limit, offset }, + accessToken, + ), + ), + ); + return { location, children }; + }, + ['location-directory-location'], + CACHE_OPTIONS, +); + +/** + * Carries a feed page without results out of unstable_cache without storing + * it: an unknown path, a page past the last one or a location without feeds. + * The feeds are fetched in parallel with the location, so caching empty + * pages would let any URL add entries to the data cache. + */ +class UncachedFeedsPage extends Error { + readonly feeds: SearchFeedsResponse; + + constructor(feeds: SearchFeedsResponse) { + super('Empty feed page'); + this.feeds = feeds; + } +} + +/** + * One page of feeds: newest first at the root (`path` null), otherwise most + * relevant to the location at `path` first. + */ +const cachedFeeds = unstable_cache( + async (offset: number, path: string | null): Promise => { + const accessToken = await getGuestGcipIdToken(); + const location = + path !== null ? { location_path: path, sort: 'location' as const } : {}; + const feeds = await requireData( + async () => + await searchFeeds( + { + query: { + ...location, + status: DIRECTORY_FEED_STATUSES, + limit: LOCATION_FEEDS_PAGE_SIZE, + offset, + }, + }, + accessToken, + ), + ); + if ((feeds.results ?? []).length === 0) throw new UncachedFeedsPage(feeds); + return feeds; + }, + ['location-directory-feeds'], + CACHE_OPTIONS, +); + +async function fetchFeeds( + offset: number, + path: string | null, +): Promise { + try { + return await cachedFeeds(offset, path); + } catch (error) { + if (error instanceof UncachedFeedsPage) return error.feeds; + throw error; + } +} + +export interface LocationPageData { + /** The current location; null at the root. */ + location: LocationResult | null; + /** Continents at the root, otherwise the locations with feeds one level down. */ + children: LocationResult[]; + /** The requested page of feeds located there. */ + feeds: SearchFeedsResponse; +} + +/** + * The location (with its children) and the feed page are fetched in + * parallel from the path alone, in one round trip. + */ +async function loadLocationPage( + page: number, + path: string, +): Promise { + const offset = getPageOffset(page); + + if (path === '') { + const [continents, feeds] = await Promise.all([ + cachedContinents(), + fetchFeeds(offset, null), + ]); + return { location: null, children: continents, feeds }; + } + + const [{ location, children }, feeds] = await Promise.all([ + cachedLocation(path), + fetchFeeds(offset, path), + ]); + return { location, children, feeds }; +} + +/** + * Everything a directory page shows, or null when the location does not + * exist. `path` is the location's API path, continent first (empty at the + * root). React cache() dedupes the generateMetadata and page calls within + * one request; it compares arguments by identity, hence primitives. + */ +export const fetchLocationPage = cache( + async (page: number, path: string = ''): Promise => + await nullWhenMissing(async () => await loadLocationPage(page, path)), +); diff --git a/src/app/[locale]/locations/lib/location-directory.spec.ts b/src/app/[locale]/locations/lib/location-directory.spec.ts new file mode 100644 index 00000000..99c03998 --- /dev/null +++ b/src/app/[locale]/locations/lib/location-directory.spec.ts @@ -0,0 +1,459 @@ +import { + buildBreadcrumbSteps, + buildLocationHref, + buildPlaceName, + fetchAllPages, + fetchRemainingPages, + filterByName, + formatCount, + getFeedCountEntries, + getLocationName, + getLocationSegments, + getPageCount, + getPageOffset, + getTotalFeedCount, + type LocationResult, + normalizeLocationName, + parseLocationSegments, + parsePageParam, + sortByFeedCount, +} from './location-directory'; + +const northAmerica: LocationResult = { + location_id: -5, + parent_location_id: null, + location_type: 'continent', + continent_code: 'NA', + name: 'North America', + slug: 'north-america', + path: 'north-america', + path_slugs: ['north-america'], + path_names: ['North America'], +}; + +// Variable depth: Montréal sits under an intermediate region. +const montreal: LocationResult = { + location_id: 175905, + parent_location_id: 161950, + location_type: 'municipality', + continent_code: 'NA', + name: 'Montréal', + slug: 'montreal', + path: 'north-america/canada/quebec/montreal-region/montreal', + path_slugs: [ + 'north-america', + 'canada', + 'quebec', + 'montreal-region', + 'montreal', + ], + path_names: [ + 'North America', + 'Canada', + 'Québec', + 'Montréal (region)', + 'Montréal', + ], +}; + +const canada: LocationResult = { + location_id: 1, + parent_location_id: -5, + location_type: 'country', + continent_code: 'NA', + name: 'Canada', + slug: 'canada', + path: 'north-america/canada', + path_slugs: ['north-america', 'canada'], + path_names: ['North America', 'Canada'], +}; + +// A country without continent in the classification. +const clipperton: LocationResult = { + location_id: 2, + parent_location_id: null, + location_type: 'country', + continent_code: null, + name: 'Clipperton', + slug: 'clipperton', + path: 'clipperton', + path_slugs: ['clipperton'], + path_names: ['Clipperton'], +}; + +describe('parseLocationSegments', () => { + it('returns no slugs at the root', () => { + expect(parseLocationSegments(undefined)).toEqual([]); + expect(parseLocationSegments([])).toEqual([]); + }); + + it('keeps every slug, continent first, at any depth', () => { + expect(parseLocationSegments(['north-america'])).toEqual(['north-america']); + const deep = [ + 'north-america', + 'canada', + 'quebec', + 'montreal-region', + 'montreal', + 'a', + ]; + expect(parseLocationSegments(deep)).toEqual(deep); + expect(parseLocationSegments(['clipperton'])).toEqual(['clipperton']); + }); + + it('accepts collision suffixes and non-Latin lowercase slugs', () => { + expect( + parseLocationSegments([ + 'north-america', + 'united-states', + 'ohio', + 'washington-township-123456', + ]), + ).toEqual([ + 'north-america', + 'united-states', + 'ohio', + 'washington-township-123456', + ]); + expect(parseLocationSegments(['asia', '東京'])).toEqual(['asia', '東京']); + }); + + it('decodes percent-encoded segments', () => { + expect(parseLocationSegments(['asia', encodeURIComponent('東京')])).toEqual( + ['asia', '東京'], + ); + }); + + it.each([ + [['north-america', '']], + [['north-america', ' ']], + [['North-America']], + [['north-america', 'Canada']], + [['north-america', 'can ada']], + [['north-america', 'can%20ada']], + [['north-america', 'canada.json']], + [['north-america', '%E0%A4%A']], + ])('rejects malformed segments %p', (segments) => { + expect(parseLocationSegments(segments)).toBeNull(); + }); +}); + +describe('parsePageParam', () => { + it('defaults to page 1 when absent', () => { + expect(parsePageParam(undefined)).toBe(1); + }); + + it('accepts positive integers', () => { + expect(parsePageParam('1')).toBe(1); + expect(parsePageParam('2')).toBe(2); + expect(parsePageParam('120')).toBe(120); + }); + + it.each(['', '0', '-1', '+2', '01', '1.5', '1e3', 'abc', ' 2', '2 '])( + 'rejects %p', + (raw) => { + expect(parsePageParam(raw)).toBeNull(); + }, + ); + + it('rejects repeated params and unsafe integers', () => { + expect(parsePageParam(['1', '2'])).toBeNull(); + expect(parsePageParam('99999999999999999999')).toBeNull(); + }); +}); + +describe('pagination math', () => { + it('computes the page count with a floor of 1', () => { + expect(getPageCount(0)).toBe(1); + expect(getPageCount(1)).toBe(1); + expect(getPageCount(20)).toBe(1); + expect(getPageCount(21)).toBe(2); + expect(getPageCount(45, 10)).toBe(5); + }); + + it('computes the API offset of a 1-based page', () => { + expect(getPageOffset(1)).toBe(0); + expect(getPageOffset(2)).toBe(20); + expect(getPageOffset(3, 10)).toBe(20); + }); +}); + +describe('buildLocationHref', () => { + it('builds the root and nested paths', () => { + expect(buildLocationHref([])).toBe('/locations'); + expect( + buildLocationHref(['north-america', 'canada', 'quebec', 'montreal']), + ).toBe('/locations/north-america/canada/quebec/montreal'); + }); + + it('adds ?page only after the first page', () => { + expect(buildLocationHref(['europe'], 1)).toBe('/locations/europe'); + expect(buildLocationHref(['europe'], 2)).toBe('/locations/europe?page=2'); + expect(buildLocationHref([], 3)).toBe('/locations?page=3'); + }); + + it('encodes unsafe characters in segments', () => { + expect(buildLocationHref(['a b', 'c/d'])).toBe('/locations/a%20b/c%2Fd'); + }); +}); + +describe('location segments and names', () => { + it('links a location through its API path', () => { + expect(getLocationSegments(montreal)).toEqual([ + 'north-america', + 'canada', + 'quebec', + 'montreal-region', + 'montreal', + ]); + expect(buildLocationHref(getLocationSegments(canada))).toBe( + '/locations/north-america/canada', + ); + expect(buildLocationHref(getLocationSegments(northAmerica))).toBe( + '/locations/north-america', + ); + expect(buildLocationHref(getLocationSegments(clipperton))).toBe( + '/locations/clipperton', + ); + }); + + it('round-trips with URL parsing', () => { + const href = buildLocationHref(getLocationSegments(montreal), 2); + expect(href).toBe( + '/locations/north-america/canada/quebec/montreal-region/montreal?page=2', + ); + const segments = href.split('?')[0].split('/').slice(2); + expect(parseLocationSegments(segments)?.join('/')).toBe(montreal.path); + }); + + it('names a location, falling back to its slug', () => { + expect(getLocationName(montreal)).toBe('Montréal'); + expect(getLocationName({ ...montreal, name: null })).toBe('montreal'); + expect(getLocationName(northAmerica)).toBe('North America'); + }); +}); + +describe('buildBreadcrumbSteps', () => { + it('is empty at the root', () => { + expect(buildBreadcrumbSteps(null)).toEqual([]); + }); + + it('has a single step on a continent', () => { + expect(buildBreadcrumbSteps(northAmerica)).toEqual([ + { label: 'North America', segments: ['north-america'] }, + ]); + }); + + it('adds one step per path name, linked by the path slug prefix', () => { + expect(buildBreadcrumbSteps(montreal)).toEqual([ + { label: 'North America', segments: ['north-america'] }, + { label: 'Canada', segments: ['north-america', 'canada'] }, + { label: 'Québec', segments: ['north-america', 'canada', 'quebec'] }, + { + label: 'Montréal (region)', + segments: ['north-america', 'canada', 'quebec', 'montreal-region'], + }, + { + label: 'Montréal', + segments: [ + 'north-america', + 'canada', + 'quebec', + 'montreal-region', + 'montreal', + ], + }, + ]); + }); + + it('starts at the country without continent', () => { + expect(buildBreadcrumbSteps(clipperton)).toEqual([ + { label: 'Clipperton', segments: ['clipperton'] }, + ]); + }); + + it('ignores unaligned names', () => { + const steps = buildBreadcrumbSteps({ + ...canada, + path_names: ['North America', 'Canada', 'Extra'], + }); + expect(steps.map((step) => step.label)).toEqual([ + 'North America', + 'Canada', + ]); + }); +}); + +describe('buildPlaceName', () => { + it('is empty at the root', () => { + expect(buildPlaceName(null)).toBe(''); + }); + + it('names a continent on its own', () => { + expect(buildPlaceName(northAmerica)).toBe('North America'); + }); + + it('lists the most specific place first, without the continent', () => { + expect(buildPlaceName(canada)).toBe('Canada'); + expect(buildPlaceName(montreal)).toBe( + 'Montréal, Montréal (region), Québec, Canada', + ); + }); + + it('keeps the country of a location without continent', () => { + expect(buildPlaceName(clipperton)).toBe('Clipperton'); + }); + + it('falls back to the name without path names', () => { + expect(buildPlaceName({ ...canada, path_names: [] })).toBe('Canada'); + }); +}); + +describe('fetchAllPages', () => { + const pageOf = ( + total: number, + offset: number, + limit: number, + ): { total: number; results: number[] } => ({ + total, + results: Array.from( + { length: Math.max(0, Math.min(limit, total - offset)) }, + (_, index) => offset + index, + ), + }); + + it('stops after the first page when it holds everything', async () => { + const fetchPage = jest.fn(async (offset: number, limit: number) => + pageOf(42, offset, limit), + ); + await expect(fetchAllPages(fetchPage)).resolves.toHaveLength(42); + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(fetchPage).toHaveBeenCalledWith(0, 100); + }); + + it('fetches the remaining pages in order', async () => { + const fetchPage = jest.fn(async (offset: number, limit: number) => + pageOf(250, offset, limit), + ); + const results = await fetchAllPages(fetchPage); + expect(results).toEqual(Array.from({ length: 250 }, (_, index) => index)); + expect(fetchPage.mock.calls).toEqual([ + [0, 100], + [100, 100], + [200, 100], + ]); + }); + + it('follows a server that returns fewer results than asked', async () => { + const fetchPage = jest.fn(async (offset: number) => pageOf(25, offset, 10)); + await expect(fetchAllPages(fetchPage)).resolves.toHaveLength(25); + expect(fetchPage.mock.calls.map(([offset]) => offset)).toEqual([0, 10, 20]); + }); + + it('completes a first page fetched elsewhere', async () => { + const fetchPage = jest.fn(async (offset: number, limit: number) => + pageOf(230, offset, limit), + ); + const results = await fetchRemainingPages(pageOf(230, 0, 100), fetchPage); + expect(results).toEqual(Array.from({ length: 230 }, (_, index) => index)); + expect(fetchPage.mock.calls).toEqual([ + [100, 100], + [200, 100], + ]); + await expect( + fetchRemainingPages(pageOf(42, 0, 100), fetchPage), + ).resolves.toHaveLength(42); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + it('handles empty and total-less responses', async () => { + await expect( + fetchAllPages(async () => ({ total: 0, results: [] })), + ).resolves.toEqual([]); + await expect(fetchAllPages(async () => ({}))).resolves.toEqual([]); + await expect( + fetchAllPages(async () => ({ results: [1, 2] })), + ).resolves.toEqual([1, 2]); + }); +}); + +describe('feed counts', () => { + it('lists GTFS, GTFS-RT and GBFS in order, defaulting to 0', () => { + expect(getFeedCountEntries({ gtfs: 4, gbfs: 1 })).toEqual([ + { dataType: 'gtfs', count: 4 }, + { dataType: 'gtfs_rt', count: 0 }, + { dataType: 'gbfs', count: 1 }, + ]); + expect(getFeedCountEntries(undefined).map((e) => e.count)).toEqual([ + 0, 0, 0, + ]); + }); + + it('prefers the distinct total over the per-type sum', () => { + expect(getTotalFeedCount({ gtfs: 2, gtfs_rt: 2, gbfs: 1, total: 4 })).toBe( + 4, + ); + expect(getTotalFeedCount({ gtfs: 2, gtfs_rt: 2, gbfs: 1 })).toBe(5); + expect(getTotalFeedCount(undefined)).toBe(0); + }); + + it('formats counts for the locale', () => { + expect(formatCount(1234, 'en')).toBe('1,234'); + expect(formatCount(undefined, 'en')).toBe('0'); + // fr groups with a narrow no-break space + expect(formatCount(1234, 'fr').replace(/\s/g, ' ')).toBe('1 234'); + }); +}); + +describe('normalizeLocationName', () => { + it('drops accents and case', () => { + expect(normalizeLocationName(' Québec ')).toBe('quebec'); + expect(normalizeLocationName('São Paulo')).toBe('sao paulo'); + }); +}); + +describe('sortByFeedCount', () => { + const item = ( + name: string, + total: number, + ): { name: string; counts: { total: number } } => ({ + name, + counts: { total }, + }); + + it('puts the most feeds first, then sorts by name', () => { + const sorted = sortByFeedCount([ + item('Laval', 3), + item('Montréal', 22), + item('Québec', 3), + item('Gatineau', 1), + item('Lévis', 3), + ]); + expect(sorted.map(({ name }) => name)).toEqual([ + 'Montréal', + 'Laval', + 'Lévis', + 'Québec', + 'Gatineau', + ]); + }); + + it('does not mutate its input', () => { + const items = [item('B', 1), item('A', 2)]; + sortByFeedCount(items); + expect(items.map(({ name }) => name)).toEqual(['B', 'A']); + }); +}); + +describe('filterByName', () => { + const items = [{ name: 'Montréal' }, { name: 'Québec' }, { name: 'Laval' }]; + + it('matches anywhere in the name, ignoring accents and case', () => { + expect(filterByName(items, 'MONTRE')).toEqual([{ name: 'Montréal' }]); + expect(filterByName(items, 'bec')).toEqual([{ name: 'Québec' }]); + }); + + it('returns every item for a blank query', () => { + expect(filterByName(items, ' ')).toEqual(items); + }); +}); diff --git a/src/app/[locale]/locations/lib/location-directory.ts b/src/app/[locale]/locations/lib/location-directory.ts new file mode 100644 index 00000000..7572b6c5 --- /dev/null +++ b/src/app/[locale]/locations/lib/location-directory.ts @@ -0,0 +1,272 @@ +/** + * Pure helpers for the /locations directory: URL segment and page parsing, + * URL and breadcrumb building, paging and count formatting. Safe to import + * from server and client components; holds no data fetching. + */ +import type { components } from '../../../services/feeds/types'; + +export type LocationResult = components['schemas']['LocationSearchResult']; +export type LocationFeedCounts = components['schemas']['LocationFeedCounts']; + +export const LOCATIONS_BASE_PATH = '/locations'; +export const LOCATION_FEEDS_PAGE_SIZE = 20; +/** Maximum `limit` of the `/v1/locations` endpoints. */ +export const LOCATIONS_API_PAGE_SIZE = 100; +/** Anchor of the feed list; pagination links jump back to it. */ +export const FEEDS_SECTION_ID = 'feeds'; + +// API slugs: lowercase letters (any script), digits and hyphens, e.g. +// `north-america`, `montreal`, `washington-township-123456`. +const SLUG_PATTERN = /^[\p{Ll}\p{Lo}\p{M}\p{Nd}-]+$/u; + +function decodeSegment(segment: string): string | null { + if (!segment.includes('%')) return segment; + try { + return decodeURIComponent(segment); + } catch { + return null; + } +} + +/** + * Maps the optional catch-all segments to the slugs of a location's API + * `path`, continent first, at any depth: `/locations/` is the + * location's `path`. + * + * @returns the slugs (empty at the root), or null when a segment is not a + * valid slug (empty, uppercase, spaces, punctuation other than `-`). + */ +export function parseLocationSegments( + segments: readonly string[] | undefined, +): string[] | null { + if (segments == undefined) return []; + const slugs: string[] = []; + for (const segment of segments) { + const slug = decodeSegment(segment); + if (slug === null || !SLUG_PATTERN.test(slug)) return null; + slugs.push(slug); + } + return slugs; +} + +const PAGE_PATTERN = /^[1-9]\d*$/; + +/** + * Parses the 1-based `?page=` search param. + * + * @returns 1 when absent, the page number when it is a positive integer + * written without leading zeros or signs, otherwise null. + */ +export function parsePageParam( + raw: string | string[] | undefined, +): number | null { + if (raw == undefined) return 1; + if (typeof raw !== 'string' || !PAGE_PATTERN.test(raw)) return null; + const page = Number(raw); + return Number.isSafeInteger(page) ? page : null; +} + +/** Number of pages needed for `total` items; never less than 1. */ +export function getPageCount( + total: number, + pageSize: number = LOCATION_FEEDS_PAGE_SIZE, +): number { + if (total <= 0) return 1; + return Math.ceil(total / pageSize); +} + +/** API offset of the first item of a 1-based page. */ +export function getPageOffset( + page: number, + pageSize: number = LOCATION_FEEDS_PAGE_SIZE, +): number { + return (page - 1) * pageSize; +} + +/** + * Locale-less path to a directory location, e.g. + * `/locations/north-america/canada/quebec?page=2`. Page 1 has no query string + * so that it stays the canonical URL of the location. Pass the result to the + * next-intl `Link`, which adds the locale prefix. + */ +export function buildLocationHref( + segments: readonly string[], + page: number = 1, +): string { + const path = [LOCATIONS_BASE_PATH, ...segments.map(encodeURIComponent)].join( + '/', + ); + return page > 1 ? `${path}?page=${page}` : path; +} + +/** Display name of a location, falling back to its slug. */ +export function getLocationName(location: LocationResult): string { + return location.name ?? location.slug ?? ''; +} + +/** + * URL segments of a location: its `path_slugs`, continent first + * (`['north-america', 'canada', 'quebec']`), so that its directory URL is + * `/locations/`. + */ +export function getLocationSegments(location: LocationResult): string[] { + return location.path_slugs; +} + +export interface BreadcrumbStep { + label: string; + /** URL segments of this step, continent first. */ + segments: string[]; +} + +/** + * One step per `path_names` entry, continent first, each linked through the + * matching prefix of `path_slugs`. Empty at the root. + */ +export function buildBreadcrumbSteps( + location: LocationResult | null, +): BreadcrumbStep[] { + if (location === null) return []; + const names = location.path_names; + const slugs = location.path_slugs; + const depth = Math.min(names.length, slugs.length); + const steps: BreadcrumbStep[] = []; + for (let index = 0; index < depth; index++) { + steps.push({ + label: names[index], + segments: slugs.slice(0, index + 1), + }); + } + return steps; +} + +/** + * Human-readable place for titles, most specific first and without the + * continent: `Montréal, Québec, Canada`. A continent is named on its own; + * empty at the root. + */ +export function buildPlaceName(location: LocationResult | null): string { + if (location === null) return ''; + if (location.location_type === 'continent') return getLocationName(location); + const names = location.path_names; + // `path_names` starts with the continent, when the country has one. + const placeNames = + location.continent_code != undefined ? names.slice(1) : names; + if (placeNames.length === 0) return getLocationName(location); + return [...placeNames].reverse().join(', '); +} + +export interface ResultsPage { + total?: number; + results?: T[]; +} + +export type PageFetcher = ( + offset: number, + limit: number, +) => Promise>; + +/** + * Completes the first page of an offset-paginated endpoint: it tells the + * total and the effective page size, the remaining pages are then fetched + * in parallel. + */ +export async function fetchRemainingPages( + first: ResultsPage, + fetchPage: PageFetcher, + pageSize: number = LOCATIONS_API_PAGE_SIZE, +): Promise { + const results = [...(first.results ?? [])]; + const total = first.total ?? results.length; + // A server that caps `limit` lower than asked still pages consistently. + const step = results.length; + if (step === 0 || step >= total) return results; + const offsets: number[] = []; + for (let offset = step; offset < total; offset += step) { + offsets.push(offset); + } + const rest = await Promise.all( + offsets.map(async (offset) => await fetchPage(offset, pageSize)), + ); + for (const page of rest) results.push(...(page.results ?? [])); + return results; +} + +/** Every result of an offset-paginated endpoint; see fetchRemainingPages. */ +export async function fetchAllPages( + fetchPage: PageFetcher, + pageSize: number = LOCATIONS_API_PAGE_SIZE, +): Promise { + const first = await fetchPage(0, pageSize); + return await fetchRemainingPages(first, fetchPage, pageSize); +} + +export const FEED_COUNT_DATA_TYPES = ['gtfs', 'gtfs_rt', 'gbfs'] as const; +export type FeedCountDataType = (typeof FEED_COUNT_DATA_TYPES)[number]; + +export interface FeedCountEntry { + dataType: FeedCountDataType; + count: number; +} + +/** GTFS, GTFS-RT and GBFS counts in display order; missing counts are 0. */ +export function getFeedCountEntries( + counts: LocationFeedCounts | undefined, +): FeedCountEntry[] { + return FEED_COUNT_DATA_TYPES.map((dataType) => ({ + dataType, + count: counts?.[dataType] ?? 0, + })); +} + +/** Total distinct feeds of a location, falling back to the sum by type. */ +export function getTotalFeedCount( + counts: LocationFeedCounts | undefined, +): number { + if (counts?.total != undefined) return counts.total; + return getFeedCountEntries(counts).reduce( + (sum, entry) => sum + entry.count, + 0, + ); +} + +/** Lowercased and without accents, so "Québec" matches "quebec". */ +export function normalizeLocationName(name: string): string { + return name + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .trim(); +} + +/** Most feeds first; ties by name, ignoring accents and case. */ +export function sortByFeedCount< + T extends { name: string; counts: LocationFeedCounts | undefined }, +>(items: readonly T[]): T[] { + return [...items].sort( + (a, b) => + getTotalFeedCount(b.counts) - getTotalFeedCount(a.counts) || + normalizeLocationName(a.name).localeCompare( + normalizeLocationName(b.name), + ), + ); +} + +/** Items whose name contains the query, ignoring accents and case. */ +export function filterByName( + items: readonly T[], + query: string, +): T[] { + const normalizedQuery = normalizeLocationName(query); + if (normalizedQuery === '') return [...items]; + return items.filter((item) => + normalizeLocationName(item.name).includes(normalizedQuery), + ); +} + +/** Locale-aware integer formatting, e.g. 1234 -> "1,234" (en). */ +export function formatCount(value: number | undefined, locale: string): string { + return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format( + value ?? 0, + ); +} diff --git a/src/app/components/Footer.tsx b/src/app/components/Footer.tsx index 35606b1e..5846b895 100644 --- a/src/app/components/Footer.tsx +++ b/src/app/components/Footer.tsx @@ -73,6 +73,7 @@ const Footer: React.FC = () => { {t('columns.platform')} {t('links.feeds')} + {t('links.locations')} {t('links.addFeed')} - {navigationItems.map((item) => ( - - - - ))} + + + ), + )} (null); + const [openedByKeyboard, setOpenedByKeyboard] = React.useState(false); + const closeTimer = React.useRef>(undefined); + + React.useEffect(() => { + return () => { + clearTimeout(closeTimer.current); + }; + }, []); + + const handleOpen = (e: React.MouseEvent): void => { + clearTimeout(closeTimer.current); + setOpenedByKeyboard(false); + setAnchorEl(e.currentTarget); + }; + + const handleClose = (): void => { + closeTimer.current = setTimeout(() => { + setAnchorEl(null); + }, 80); + }; + + const closeNow = (): void => { + clearTimeout(closeTimer.current); + setAnchorEl(null); + }; + + return ( + + + { + clearTimeout(closeTimer.current); + }, + onMouseLeave: handleClose, + sx: { pointerEvents: 'auto', minWidth: 200 }, + }, + }} + > + + {feedsMenuItems.map((item) => ( + + {t(item.titleKey)} + + ))} + + + + ); +} diff --git a/src/app/components/HeaderMobileDrawer.tsx b/src/app/components/HeaderMobileDrawer.tsx index 962f3dbe..f0e3984f 100644 --- a/src/app/components/HeaderMobileDrawer.tsx +++ b/src/app/components/HeaderMobileDrawer.tsx @@ -21,6 +21,7 @@ import { useColorScheme } from '@mui/material/styles'; import { useRouter } from 'next/navigation'; import { ACCOUNT_TARGET, + feedsMenuItems, gbfsMetricsNavItems, gtfsMetricsNavItems, SIGN_IN_TARGET, @@ -140,20 +141,56 @@ export default function DrawerContent({ - {navigationItems.map((item) => ( - - ))} + {navigationItems.map((item) => + item.target === 'feeds' ? ( + + } + aria-controls='feeds-content' + id='feeds-header' + data-cy='drawer-feeds' + > + + {item.title} + + + + {feedsMenuItems.map((subItem) => ( + + ))} + + + ) : ( + + ), + )} diff --git a/src/app/constants/Navigation.ts b/src/app/constants/Navigation.ts index 77d4454e..c3c65a48 100644 --- a/src/app/constants/Navigation.ts +++ b/src/app/constants/Navigation.ts @@ -15,6 +15,22 @@ export const MOBILITY_DATA_LINKS = { github: 'https://github.com/MobilityData/mobility-database-catalogs', }; +export const FEEDS_TARGET = '/feeds'; +export const LOCATIONS_TARGET = '/locations'; + +/** + * Sub-items of the "Feeds" header menu (desktop dropdown and mobile drawer). + * `titleKey` is a key of the `common` messages namespace. + */ +export const feedsMenuItems = [ + { titleKey: 'search', target: FEEDS_TARGET, dataCy: 'header-feeds-search' }, + { + titleKey: 'locations', + target: LOCATIONS_TARGET, + dataCy: 'header-feeds-locations', + }, +] as const; + export const WEB_VALIDATOR_LINK = 'https://gtfs-validator.mobilitydata.org'; export const GTFS_ORG_LINK = 'https://gtfs.org'; export const GBFS_LINK = 'https://github.com/MobilityData/gbfs'; diff --git a/src/app/screens/Feeds/SearchTable.tsx b/src/app/screens/Feeds/SearchTable.tsx index 2e990d4b..130b9fad 100644 --- a/src/app/screens/Feeds/SearchTable.tsx +++ b/src/app/screens/Feeds/SearchTable.tsx @@ -39,35 +39,37 @@ const HeaderTableCell = styled(TableCell)(() => ({ border: 'none', })); +const DATA_TYPE_LABEL_KEYS = { + gtfs: 'gtfsSchedule', + gtfs_rt: 'gtfsRealtime', + gbfs: 'gbfs', +} as const; + +function DataTypeElement({ + dataType, +}: { + dataType: 'gtfs' | 'gtfs_rt' | 'gbfs'; +}): React.ReactElement { + const tCommon = useTranslations('common'); + return ( + + {tCommon(DATA_TYPE_LABEL_KEYS[dataType])} + + ); +} + +// The translation hook lives in DataTypeElement, not here: this is called once +// per row, and a hook called in a loop breaks when the row count changes +// between renders of the same table (e.g. paging to a partial last page). export const getDataTypeElement = ( dataType: 'gtfs' | 'gtfs_rt' | 'gbfs', -): React.ReactElement => { - const tCommon = useTranslations('common'); - const DataTypeHolder = ({ - children, - }: { - children: React.ReactNode; - }): React.ReactElement => { - return ( - - {children} - - ); - }; - if (dataType === 'gtfs') { - return {tCommon('gtfsSchedule')}; - } else if (dataType === 'gtfs_rt') { - return {tCommon('gtfsRealtime')}; - } else { - return {tCommon('gbfs')}; - } -}; +): React.ReactElement => ; export default function SearchTable({ feedsData, diff --git a/src/app/services/feeds/index.ts b/src/app/services/feeds/index.ts index 50b726c2..4dc6dc23 100644 --- a/src/app/services/feeds/index.ts +++ b/src/app/services/feeds/index.ts @@ -1,5 +1,5 @@ import createClient, { type Middleware } from 'openapi-fetch'; -import type { paths } from './types'; +import type { components, paths } from './types'; import { type AllFeedsParams, type AllFeedType } from './utils'; import { type GtfsRoute } from '../../types'; import { getFeedFilesBaseUrl } from '../../utils/config'; @@ -17,6 +17,21 @@ const client = createClient({ }, }); +/** + * Error thrown for any API response with a status >= 400. Extends `Error` + * with the same message as before, adding the HTTP status so callers can + * tell a 404 apart from a server failure. + */ +export class ApiError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + const throwOnError: Middleware = { async onResponse(res) { if (res.status >= 400) { @@ -26,7 +41,7 @@ const throwOnError: Middleware = { } const errorMessage = typeof body === 'string' ? body : JSON.stringify(body); - throw new Error(errorMessage); + throw new ApiError(errorMessage, res.status); } return undefined; }, @@ -333,14 +348,18 @@ export const getMetadata = async (): Promise< }); }; +export type SearchFeedsResponse = + paths['/v1/search']['get']['responses'][200]['content']['application/json']; + +/** + * Also lists the feeds of a location directory page, with `location_path` + * (or `location_id`) and `sort: 'location'`. + */ export const searchFeeds = async ( params: AllFeedsParams, accessToken: string, userContextJwt?: string, -): Promise< - | paths['/v1/search']['get']['responses'][200]['content']['application/json'] - | undefined -> => { +): Promise => { const authMiddleware = generateAuthMiddlewareWithToken( accessToken, userContextJwt, @@ -351,6 +370,117 @@ export const searchFeeds = async ( }); }; +export type LocationsParams = NonNullable< + paths['/v1/locations']['get']['parameters']['query'] +>; + +export type LocationsResponse = + paths['/v1/locations']['get']['responses'][200]['content']['application/json']; + +/** + * Searches locations (continents, countries, subdivisions, municipalities). + * Returns at most 100 results per call: page with `limit` and `offset`. + * Throws an `ApiError` with status 400 on an unsupported `sort` or `include`. + */ +export const getLocations = async ( + params: LocationsParams, + accessToken: string, + userContextJwt?: string, +): Promise => { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/locations', { + params: { query: params }, + }); + return response.data; + }); +}; + +export type LocationDetail = components['schemas']['LocationDetail']; + +export type LocationLookupParams = + paths['/v1/locations/lookup']['get']['parameters']['query']; + +/** + * One location from its `path` (e.g. `north-america/canada/quebec`), with + * the first page of its children when `include` has `children`; `has_feeds`, + * `sort`, `limit` and `offset` then apply to the children. + * Throws an `ApiError` with status 404 when no location has that path. + */ +export const getLocationByPath = async ( + params: LocationLookupParams, + accessToken: string, + userContextJwt?: string, +): Promise => { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/locations/lookup', { + params: { query: params }, + }); + return response.data; + }); +}; + +export type LocationParams = NonNullable< + paths['/v1/locations/{location_id}']['get']['parameters']['query'] +>; + +/** + * One location from its `location_id` (negative for a continent), same + * response as `getLocationByPath`. + * Throws an `ApiError` with status 404 when no location has that id. + */ +export const getLocation = async ( + locationId: number, + params: LocationParams, + accessToken: string, + userContextJwt?: string, +): Promise => { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/locations/{location_id}', { + params: { path: { location_id: locationId }, query: params }, + }); + return response.data; + }); +}; + +export type LocationChildrenParams = NonNullable< + paths['/v1/locations/{location_id}/children']['get']['parameters']['query'] +>; + +/** + * The direct children of a location, at most 100 per call: page with + * `limit` and `offset`. + * Throws an `ApiError` with status 404 when no location has that id. + */ +export const getLocationChildren = async ( + locationId: number, + params: LocationChildrenParams, + accessToken: string, + userContextJwt?: string, +): Promise => { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/locations/{location_id}/children', { + params: { path: { location_id: locationId }, query: params }, + }); + return response.data; + }); +}; + export const getLicense = async ( id: string, accessToken: string, diff --git a/src/app/services/feeds/types.ts b/src/app/services/feeds/types.ts index c3be5015..83f7a47d 100644 --- a/src/app/services/feeds/types.ts +++ b/src/app/services/feeds/types.ts @@ -331,6 +331,17 @@ export interface paths { * Query: New York Transit *
    * Search Executed: 'new' & york & 'transit' + *

    + * Location filters: + *
    + * - `location_id` (any location of `/v1/locations`, continents included) or `location_path` (its `path`) + * restricts the results to feeds with stops in that location, sub-areas included. Feeds are matched through + * their reverse-geolocated stops, so a feed that was never geolocated does not match. A GTFS Realtime feed that + * was never geolocated matches the locations of the schedule feeds it references. Unknown values return no + * feed. Pass one of the two, not both. + * - To list the same feeds as a location's `feed_counts`, also pass `status=active,inactive,development,future`. + * - `sort=location` orders the results by the share of each feed's stops inside the requested location instead + * of by creation date. *
    */ get: operations['searchFeeds']; @@ -349,7 +360,7 @@ export interface paths { path?: never; cookie?: never; }; - /** @description Search locations (countries, subdivisions and municipalities). Results can be filtered by a free-text query and narrowed to a specific country, subdivision or location type. Matches are ordered from the broadest area to the most specific, and by relevance within each level. */ + /** @description Search and filter locations: continents, countries, subdivisions and municipalities. Results can be filtered by a free-text query, a continent, a country, a subdivision or a location type. Matches are ordered from the broadest area to the most specific, and by relevance within each level, unless `sort=feed_count` is given.
    To get one location, use `/v1/locations/{location_id}` or, from its path, `/v1/locations/lookup`; to list a location's direct children, use `/v1/locations/{location_id}/children` or `include=children` on those two endpoints. To list a location's feeds, call `/v1/search` with its `location_id` (or its `path` as `location_path`), `status=active,inactive,development,future` and `sort=location`.
    Every location has a `location_id` and a `path`. Countries, subdivisions and municipalities are OpenStreetMap administrative areas and their `location_id` is the OSM id. Continents come from a fixed country-to-continent classification; their `location_id` is negative (`-1` Africa, `-2` Antarctica, `-3` Asia, `-4` Europe, `-5` North America, `-6` Oceania, `-7` South America) and they are the parent of their countries.
    Feed counts (`include=feed_counts`, `has_feeds`, `sort=feed_count`) count distinct feeds with a status of `active`, `inactive`, `development` or `future` that have reverse-geolocated stops in the location, sub-areas included. A GTFS Realtime feed that was never geolocated is counted in the locations of the schedule feeds it references. Work-in-progress feeds are counted only for callers allowed to see them, as in `/v1/search`. */ get: operations['getLocations']; put?: never; post?: never; @@ -359,6 +370,57 @@ export interface paths { patch?: never; trace?: never; }; + '/v1/locations/lookup': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get one location from its `path`: the slugs of the location and of every area containing it, from the continent down, joined with `/` (e.g. `north-america/canada/quebec`). A continent's path is its slug (`north-america`); a country without continent in the classification starts at the country. This is the same location as `/v1/locations/{location_id}`, addressed by path instead of id.
    With `include=children`, the response also holds the first page of the location's direct children; `has_feeds`, `sort`, `limit` and `offset` then apply to the children. Paths follow OpenStreetMap names and can change when a place is renamed; store the `location_id` rather than the path. */ + get: operations['getLocationByPath']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v1/locations/{location_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get one location by its `location_id`. With `include=children`, the response also holds the first page of the location's direct children; `has_feeds`, `sort`, `limit` and `offset` then apply to the children. */ + get: operations['getLocation']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v1/locations/{location_id}/children': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List the direct children of a location, one level down: the countries of a continent, the subdivisions of a country, and so on. Depth varies by country, so the children of a subdivision can be regions or municipalities. */ + get: operations['getLocationChildren']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/v1/licenses': { parameters: { query?: never; @@ -1308,20 +1370,34 @@ export interface components { */ municipality?: string; }; + /** @description Number of distinct feeds per data type with stops in a location, sub-areas included. */ + LocationFeedCounts: { + /** @example 42 */ + gtfs?: number; + /** @example 18 */ + gtfs_rt?: number; + /** @example 7 */ + gbfs?: number; + /** + * @description Number of distinct feeds of any data type. + * @example 67 + */ + total?: number; + }; LocationSearchResponse: { /** @description The total number of matching locations regardless of limit and offset. */ - total?: number; + total: number; /** @description The page of matching locations, ordered from the broadest area to the most specific and by relevance within each level. */ - results?: components['schemas']['LocationSearchResult'][]; + results: components['schemas']['LocationSearchResult'][]; }; LocationSearchResult: { /** - * @description Stable location identifier. + * @description Stable location identifier: the OpenStreetMap id of the area, or a negative id for a continent (`-1` to `-7`). * @example 175905 */ - location_id?: number; + location_id: number; /** - * @description Stable identifier of the nearest containing location. + * @description Identifier of the nearest containing location; for a country, its continent. Null for continents and for countries without continent in the classification. * @example 161950 */ parent_location_id?: number | null; @@ -1336,11 +1412,38 @@ export interface components { */ alt_name?: string | null; /** - * @description The type of location: `country` (has an ISO 3166-1 code), `subdivision` (has an ISO 3166-2 code) or `municipality` (a locality below the subdivision level). + * @description The type of location: `continent`, `country` (has an ISO 3166-1 code), `subdivision` (has an ISO 3166-2 code) or `municipality` (a locality below the subdivision level). * @example municipality * @enum {string} */ - location_type?: 'country' | 'subdivision' | 'municipality'; + location_type: 'continent' | 'country' | 'subdivision' | 'municipality'; + /** + * @description Code of the continent that contains this location (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Null when the country has no continent in the classification. + * @example NA + */ + continent_code?: string | null; + /** + * @description URL-safe identifier of the location among its siblings: its name lowercased, without accents, with every run of non-alphanumeric characters replaced by a single hyphen (`Montréal` -> `montreal`). When siblings under the same parent would share a slug, each of them gets `-` appended. A name without any ASCII letter or digit uses its `location_id` as the slug. A continent's slug is fixed (`north-america`). + * @example montreal + */ + slug: string; + /** + * @description The location's `path_slugs` joined with `/`, from the continent down, as accepted by `/v1/locations/lookup` and by the `location_path` parameter of `/v1/search`. Paths follow OpenStreetMap names and can change when a place is renamed; store the `location_id` instead. + * @example north-america/canada/quebec/montreal-region/montreal + */ + path: string; + /** + * @description The slugs of the locations in `path_names`, in the same order. + * @example [ + * "north-america", + * "canada", + * "quebec", + * "montreal-region", + * "montreal" + * ] + */ + path_slugs: string[]; + feed_counts?: components['schemas']['LocationFeedCounts']; /** * @description The name of the country that contains this location. * @example Canada @@ -1362,21 +1465,25 @@ export interface components { */ subdivision_code?: string | null; /** - * @description The ordered list of location names from the broadest containing area down to this location. + * @description The ordered list of location names from the broadest containing area, the continent, down to this location. A country without continent in the classification starts at the country. * @example [ + * "North America", * "Canada", * "Quebec", * "Montréal (region)", * "Montréal" * ] */ - path_names?: string[]; + path_names: string[]; /** - * @description A human-readable representation of the full location hierarchy, joined from the broadest area to this location. + * @description A human-readable representation of the location hierarchy, joined from the country (or, for a continent, the continent itself) down to this location. * @example Canada, Quebec, Montréal (region), Montréal */ display_name?: string | null; }; + LocationDetail: components['schemas']['LocationSearchResult'] & { + children?: components['schemas']['LocationSearchResponse']; + }; BasicDataset: { /** * @description Unique identifier used as a key for the datasets table. @@ -1822,6 +1929,26 @@ export interface components { is_official_query_param: boolean; /** @description If true, only return feeds that currently hold the Seal of Reliability; if false, only feeds without it. Viewing a feed's seal is public, but filtering the catalogue by it is granted per user and requires the `isSealFilterEnabled` feature flag - other callers receive a 403. To request access or learn more, contact us at api@mobilitydata.org. */ has_seal_query_param: boolean; + /** @description Limit results to locations in this continent, the continent included, given as its slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ + location_continent: string; + /** @description A `location_id` from `/v1/locations`, continents included: only feeds with reverse-geolocated stops in that location, sub-areas included. A GTFS Realtime feed that was never geolocated takes the locations of the schedule feeds it references. */ + location_id_query_param: number; + /** @description A location's `path` (e.g. `north-america/canada/quebec`), as an alternative to `location_id`: only feeds with reverse-geolocated stops in that location, sub-areas included. Case-insensitive; leading and trailing slashes are ignored. */ + location_path_query_param: string; + /** @description The location's `location_id`; negative for a continent. */ + location_id_path_param: number; + /** @description When true, only return locations with at least one feed (see the feed count rules of `/v1/locations`). */ + has_feeds_query_param: boolean; + /** @description With `include=children`, only return the children with at least one feed. */ + children_has_feeds_query_param: boolean; + /** @description Optional data to add to each location: `feed_counts`, the location's feed counts per data type. */ + locations_include: 'feed_counts'[]; + /** @description Optional data to add, comma-separated: `feed_counts`, the feed counts of the location (and of its children when they are included); `children`, the first page of the location's direct children. */ + location_include: ('feed_counts' | 'children')[]; + /** @description With `include=children`, the sort order of the children. `admin_level` (default): from the broadest area to the most specific, then by display name. `feed_count`: most feeds first, then by display name. */ + children_sort: 'admin_level' | 'feed_count'; + /** @description Sort order of the results. `admin_level` (default): from the broadest area to the most specific, then by relevance to `search_query` when one is given, then by display name. `feed_count`: most feeds first, then by display name. */ + locations_sort: 'admin_level' | 'feed_count'; /** @description The number of items to be returned. */ limit_query_param_locations_endpoint: number; /** @description The number of items to be returned. */ @@ -1842,6 +1969,8 @@ export interface components { offset: number; /** @description General search query to match against transit provider, location, and feed name. */ search_text_query_param: string; + /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `location_id` or `location_path`. Feeds with the largest share of their reverse-geolocated stops inside the location come first (on Montréal, a city bus feed with all its stops in Montréal ranks above a regional train with a fifth of them there); ties are broken by provider, feed name and feed id. */ + search_sort: 'created_at' | 'location'; /** @description Comma separated list of GBFS versions to filter by. */ version_query_param: string; /** @description Comma separated list of data types to filter by. Valid values are gtfs, gtfs_rt and gbfs. */ @@ -2492,6 +2621,12 @@ export interface operations { license_is_spdx?: components['parameters']['license_is_spdx']; /** @description Comma separated list of tags to filter feeds by their license tags. */ license_tags?: components['parameters']['license_tags']; + /** @description A `location_id` from `/v1/locations`, continents included: only feeds with reverse-geolocated stops in that location, sub-areas included. A GTFS Realtime feed that was never geolocated takes the locations of the schedule feeds it references. */ + location_id?: components['parameters']['location_id_query_param']; + /** @description A location's `path` (e.g. `north-america/canada/quebec`), as an alternative to `location_id`: only feeds with reverse-geolocated stops in that location, sub-areas included. Case-insensitive; leading and trailing slashes are ignored. */ + location_path?: components['parameters']['location_path_query_param']; + /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `location_id` or `location_path`. Feeds with the largest share of their reverse-geolocated stops inside the location come first (on Montréal, a city bus feed with all its stops in Montréal ranks above a regional train with a fifth of them there); ties are broken by provider, feed name and feed id. */ + sort?: components['parameters']['search_sort']; }; header?: never; path?: never; @@ -2512,6 +2647,13 @@ export interface operations { }; }; }; + /** @description `sort=location` was requested without `location_id` or `location_path`, both `location_id` and `location_path` were given, or `sort` has an unsupported value. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Filtering by Seal of Reliability status is not available to this caller. */ 403: { headers: { @@ -2530,17 +2672,30 @@ export interface operations { offset?: components['parameters']['offset']; /** @description Free-text search matched against the location name, alternate name and its full hierarchy (e.g. "Canada, Quebec, Montréal"). Matching is accent-insensitive and supports typeahead-style prefix matching, so "mon" matches "Montréal". When several words are provided, all of them must match. */ search_query?: string; - /** @description Limit results to locations contained within this country, given as its ISO 3166-1 alpha-2 code. Case-insensitive. */ + /** @description Limit results to locations in this continent, the continent included, given as its slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ + continent?: components['parameters']['location_continent']; + /** @description Limit results to locations contained within this country, the country included, given as its ISO 3166-1 alpha-2 code. Case-insensitive. */ country_code?: string; - /** @description Limit results to locations contained within this subdivision, given as its ISO 3166-2 code. Case-insensitive. */ + /** @description Limit results to locations contained within this subdivision, the subdivision included, given as its ISO 3166-2 code. Case-insensitive. */ subdivision_code?: string; /** * @description Filter by the type of location: + * * `continent` - a continent, from a fixed country-to-continent classification. * * `country` - a sovereign country, identified by an ISO 3166-1 code. * * `subdivision` - a first-level subdivision (e.g. state or province), identified by an ISO 3166-2 code. * * `municipality` - a locality below the subdivision level (e.g. a city or town). */ - location_type?: 'country' | 'subdivision' | 'municipality'; + location_type?: + | 'continent' + | 'country' + | 'subdivision' + | 'municipality'; + /** @description When true, only return locations with at least one feed (see the feed count rules of `/v1/locations`). */ + has_feeds?: components['parameters']['has_feeds_query_param']; + /** @description Optional data to add to each location: `feed_counts`, the location's feed counts per data type. */ + include?: components['parameters']['locations_include']; + /** @description Sort order of the results. `admin_level` (default): from the broadest area to the most specific, then by relevance to `search_query` when one is given, then by display name. `feed_count`: most feeds first, then by display name. */ + sort?: components['parameters']['locations_sort']; }; header?: never; path?: never; @@ -2557,6 +2712,156 @@ export interface operations { 'application/json': components['schemas']['LocationSearchResponse']; }; }; + /** @description `sort` or `include` has an unsupported value. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getLocationByPath: { + parameters: { + query: { + /** @description The location's `path`. Case-insensitive; leading and trailing slashes are ignored. */ + path: string; + /** @description Optional data to add, comma-separated: `feed_counts`, the feed counts of the location (and of its children when they are included); `children`, the first page of the location's direct children. */ + include?: components['parameters']['location_include']; + /** @description With `include=children`, only return the children with at least one feed. */ + has_feeds?: components['parameters']['children_has_feeds_query_param']; + /** @description With `include=children`, the sort order of the children. `admin_level` (default): from the broadest area to the most specific, then by display name. `feed_count`: most feeds first, then by display name. */ + sort?: components['parameters']['children_sort']; + /** @description The number of items to be returned. */ + limit?: components['parameters']['limit_query_param_locations_endpoint']; + /** @description Offset of the first item to return. */ + offset?: components['parameters']['offset']; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The location at that path. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LocationDetail']; + }; + }; + /** @description `path` is empty, or `sort` or `include` has an unsupported value. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No location has that path. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getLocation: { + parameters: { + query?: { + /** @description Optional data to add, comma-separated: `feed_counts`, the feed counts of the location (and of its children when they are included); `children`, the first page of the location's direct children. */ + include?: components['parameters']['location_include']; + /** @description With `include=children`, only return the children with at least one feed. */ + has_feeds?: components['parameters']['children_has_feeds_query_param']; + /** @description With `include=children`, the sort order of the children. `admin_level` (default): from the broadest area to the most specific, then by display name. `feed_count`: most feeds first, then by display name. */ + sort?: components['parameters']['children_sort']; + /** @description The number of items to be returned. */ + limit?: components['parameters']['limit_query_param_locations_endpoint']; + /** @description Offset of the first item to return. */ + offset?: components['parameters']['offset']; + }; + header?: never; + path: { + /** @description The location's `location_id`; negative for a continent. */ + location_id: components['parameters']['location_id_path_param']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The location. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LocationDetail']; + }; + }; + /** @description `sort` or `include` has an unsupported value. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No location has that id. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getLocationChildren: { + parameters: { + query?: { + /** @description The number of items to be returned. */ + limit?: components['parameters']['limit_query_param_locations_endpoint']; + /** @description Offset of the first item to return. */ + offset?: components['parameters']['offset']; + /** @description When true, only return locations with at least one feed (see the feed count rules of `/v1/locations`). */ + has_feeds?: components['parameters']['has_feeds_query_param']; + /** @description Optional data to add to each location: `feed_counts`, the location's feed counts per data type. */ + include?: components['parameters']['locations_include']; + /** @description Sort order of the results. `admin_level` (default): from the broadest area to the most specific, then by relevance to `search_query` when one is given, then by display name. `feed_count`: most feeds first, then by display name. */ + sort?: components['parameters']['locations_sort']; + }; + header?: never; + path: { + /** @description The location's `location_id`; negative for a continent. */ + location_id: components['parameters']['location_id_path_param']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The children of the location. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LocationSearchResponse']; + }; + }; + /** @description `sort` or `include` has an unsupported value. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No location has that id. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; getLicenses: { diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 0f826fe3..260753b9 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -7,10 +7,51 @@ import datasetsFeedJson from '../../cypress/fixtures/feed_datasets_test-516.json import feed2947 from '../../cypress/fixtures/feed_mdb-2947.json'; import gtfsFeed2947 from '../../cypress/fixtures/gtfs_feed_mdb-2947.json'; import datasets2947 from '../../cypress/fixtures/feed_datasets_mdb-2947.json'; +import { + getMockLocation, + getMockLocationByPath, + getMockLocationChildren, + getMockLocationFeeds, + getMockLocations, + isLocationSearch, +} from './locationDirectory'; export const handlers = [ - // Mock search endpoint: return a list including test-516 and mdb-2947 - http.get(`*/v1/search`, () => { + // Locations: list and search, with feed counts + http.get(`*/v1/locations`, ({ request }) => { + const params = new URL(request.url).searchParams; + const { status, body } = getMockLocations(params); + return HttpResponse.json(body, { status }); + }), + // One location by path, with its first page of children; before the + // `{location_id}` route so that `lookup` is not read as an id. + http.get(`*/v1/locations/lookup`, ({ request }) => { + const params = new URL(request.url).searchParams; + const { status, body } = getMockLocationByPath(params); + return HttpResponse.json(body, { status }); + }), + http.get(`*/v1/locations/:locationId/children`, ({ request, params }) => { + const query = new URL(request.url).searchParams; + const { status, body } = getMockLocationChildren( + String(params.locationId), + query, + ); + return HttpResponse.json(body, { status }); + }), + http.get(`*/v1/locations/:locationId`, ({ request, params }) => { + const query = new URL(request.url).searchParams; + const { status, body } = getMockLocation(String(params.locationId), query); + return HttpResponse.json(body, { status }); + }), + // Mock search endpoint. With `location_id` or `location_path` (location + // directory pages): the feeds located there, paginated and sorted. + // Otherwise: a list including test-516 and mdb-2947. + http.get(`*/v1/search`, ({ request }) => { + const params = new URL(request.url).searchParams; + if (isLocationSearch(params)) { + const { status, body } = getMockLocationFeeds(params); + return HttpResponse.json(body, { status }); + } return HttpResponse.json({ total: 2, results: [ diff --git a/src/mocks/locationDirectory.spec.ts b/src/mocks/locationDirectory.spec.ts new file mode 100644 index 00000000..72d9b2b2 --- /dev/null +++ b/src/mocks/locationDirectory.spec.ts @@ -0,0 +1,220 @@ +import { + getMockLocation, + getMockLocationByPath, + getMockLocationChildren, + getMockLocationFeeds, + getMockLocations, + isLocationSearch, + slugify, +} from './locationDirectory'; + +type Locations = ReturnType; +type Detail = ReturnType; +type Feeds = ReturnType; + +function ok(result: { status: number; body: unknown }, label: string): T { + if (result.status !== 200) throw new Error(`expected 200 for ${label}`); + return result.body as T; +} + +type LocationsBody = Extract['body']; +type DetailBody = Extract['body']; +type FeedsBody = Extract['body']; + +function locations(query: string): LocationsBody { + return ok(getMockLocations(new URLSearchParams(query)), query); +} + +function lookup(path: string, query: string = ''): DetailBody { + return ok( + getMockLocationByPath(new URLSearchParams(`path=${path}&${query}`)), + path, + ); +} + +function children(id: number | undefined, query: string = ''): LocationsBody { + return ok( + getMockLocationChildren(String(id), new URLSearchParams(query)), + String(id), + ); +} + +function feeds(query: string): FeedsBody { + return ok(getMockLocationFeeds(new URLSearchParams(query)), query); +} + +describe('location directory mocks', () => { + it('slugifies names like the API', () => { + expect(slugify('Québec')).toBe('quebec'); + expect(slugify('Saint-Jean-sur-Richelieu')).toBe( + 'saint-jean-sur-richelieu', + ); + expect(slugify('Île-de-France')).toBe('ile-de-france'); + }); + + it('lists the continents with feeds, most feeds first', () => { + const all = locations('location_type=continent&include=feed_counts'); + expect(all.total).toBe(7); + const antarctica = all.results?.find((row) => row.slug === 'antarctica'); + expect(antarctica).toMatchObject({ + location_id: -2, + parent_location_id: null, + path: 'antarctica', + path_slugs: ['antarctica'], + path_names: ['Antarctica'], + feed_counts: { total: 0 }, + }); + + const { results } = locations( + 'location_type=continent&has_feeds=true&include=feed_counts&sort=feed_count', + ); + expect(results?.map((row) => row.location_id)).toEqual([-5, -4, -6, -1]); + expect(results?.[0].path).toBe('north-america'); + }); + + it('starts every path at the continent', () => { + const quebec = lookup('north-america/canada/quebec'); + expect(quebec).toMatchObject({ + location_id: 110, + parent_location_id: 100, + continent_code: 'NA', + path: 'north-america/canada/quebec', + path_slugs: ['north-america', 'canada', 'quebec'], + path_names: ['North America', 'Canada', 'Québec'], + display_name: 'Canada, Québec', + }); + expect(lookup('north-america/canada').parent_location_id).toBe(-5); + expect(lookup('clipperton')).toMatchObject({ + parent_location_id: null, + continent_code: null, + path_slugs: ['clipperton'], + }); + }); + + it('looks a path up with its children and rolls counts up', () => { + const montreal = lookup( + 'north-america/canada/quebec/montreal', + 'include=feed_counts', + ); + expect(montreal.feed_counts?.total).toBe(22); + expect(montreal.children).toBeUndefined(); + + const quebec = lookup( + '/North-America/canada/quebec/', + 'include=feed_counts,children&has_feeds=true&sort=feed_count&limit=2', + ); + expect(quebec.feed_counts?.total).toBeGreaterThan(22); + expect(quebec.children?.total).toBe(4); + // Laval and Québec tie on 2 feeds: by display name. + expect(quebec.children?.results?.map((row) => row.slug)).toEqual([ + 'montreal', + 'laval', + ]); + expect(quebec.children?.results?.[0].feed_counts?.total).toBe(22); + }); + + it('answers unknown paths and ids with a 404', () => { + const notFound = (params: string): number => + getMockLocationByPath(new URLSearchParams(params)).status; + expect(notFound('path=north-america/canada/nowhere')).toBe(404); + // Paths start at the continent. + expect(notFound('path=canada')).toBe(404); + expect(notFound('path=')).toBe(400); + expect(notFound('path=europe&include=everything')).toBe(400); + expect(getMockLocation('42', new URLSearchParams()).status).toBe(404); + expect(getMockLocationChildren('42', new URLSearchParams()).status).toBe( + 404, + ); + }); + + it('gets a location by id, continents included', () => { + const europe = ok( + getMockLocation('-4', new URLSearchParams('include=children')), + 'europe', + ); + expect(europe.path).toBe('europe'); + expect(europe.children?.results?.map((row) => row.slug)).toEqual([ + 'france', + 'germany', + ]); + }); + + it('has a variable depth and collision-suffixed slugs', () => { + expect( + lookup( + 'north-america/united-states/california/los-angeles-county/los-angeles', + ).path_names, + ).toHaveLength(5); + expect( + lookup('north-america/united-states/california/san-francisco').path_slugs, + ).toHaveLength(4); + const ohio = lookup('north-america/united-states/ohio'); + expect( + children(ohio.location_id, 'has_feeds=true').results?.map( + (row) => row.path, + ), + ).toEqual([ + 'north-america/united-states/ohio/washington-township-123456', + 'north-america/united-states/ohio/washington-township-123457', + ]); + }); + + it('pages children and rejects unsupported parameters', () => { + const quebec = lookup('north-america/canada/quebec'); + const all = children(quebec.location_id); + const second = children(quebec.location_id, 'limit=2&offset=2'); + expect(second.total).toBe(all.total); + expect(second.results).toEqual(all.results?.slice(2, 4)); + expect(all.results?.[0].feed_counts).toBeUndefined(); + expect(getMockLocations(new URLSearchParams('sort=name')).status).toBe(400); + expect(getMockLocations(new URLSearchParams('limit=101')).status).toBe(400); + expect( + getMockLocations(new URLSearchParams('include=children')).status, + ).toBe(400); + }); + + it('filters the list by continent, slug or code', () => { + const bySlug = locations('continent=europe&location_type=country'); + const byCode = locations('continent=EU&location_type=country'); + expect(bySlug.results?.map((row) => row.slug)).toEqual([ + 'france', + 'germany', + ]); + expect(byCode).toEqual(bySlug); + }); + + it('routes location searches only', () => { + expect(isLocationSearch(new URLSearchParams('location_id=1'))).toBe(true); + expect(isLocationSearch(new URLSearchParams('location_path=europe'))).toBe( + true, + ); + expect(isLocationSearch(new URLSearchParams('status=active'))).toBe(false); + }); + + it('paginates the feeds of a location, most relevant first', () => { + const query = + 'location_path=north-america/canada/quebec/montreal&sort=location&limit=20'; + const first = feeds(query); + const second = feeds(`${query}&offset=20`); + expect(first.total).toBe(22); + expect(first.results).toHaveLength(20); + expect(second.results).toHaveLength(2); + // The commuter train also serves Laval and Longueuil: least relevant. + expect(second.results[1].feed_name).toBe('Commuter trains'); + expect(feeds('location_id=111&sort=location&limit=20')).toEqual(first); + }); + + it('lists the feeds of a continent by id or path', () => { + expect(feeds('location_path=europe&sort=location').total).toBe(3); + expect(feeds('location_id=-4&sort=location').total).toBe(3); + expect(feeds('location_path=asia').total).toBe(0); + expect(feeds('location_path=atlantis').total).toBe(0); + }); + + it('rejects sort=location alone and both location parameters', () => { + const status = (query: string): number => + getMockLocationFeeds(new URLSearchParams(query)).status; + expect(status('sort=location')).toBe(400); + expect(status('location_id=-4&location_path=europe')).toBe(400); + }); +}); diff --git a/src/mocks/locationDirectory.ts b/src/mocks/locationDirectory.ts new file mode 100644 index 00000000..2474e71d --- /dev/null +++ b/src/mocks/locationDirectory.ts @@ -0,0 +1,637 @@ +/** + * Mock data and logic for the location directory endpoints: + * GET /v1/locations (list and search) + * GET /v1/locations/lookup (one location by path, with its children) + * GET /v1/locations/{location_id} and /v1/locations/{location_id}/children + * GET /v1/search with `location_id` / `location_path` (a location's feeds) + * + * Locations form a tree of variable depth under the continents (Los Angeles + * sits under a county, San Francisco directly under California). Continents + * have negative ids and parent their countries; a country without continent + * starts its own path. Feed counts, `has_feeds` and the `sort=location` + * relevance are derived from MOCK_FEEDS' stop locations, so the mock behaves + * like the API: a feed counts once per location, sub-areas included. + * Location ids, feed ids, URLs and names are fictional mock data. + */ +import type { components } from '../app/services/feeds/types'; + +type Feed = components['schemas']['SearchFeedItemResult']; +type LocationResult = components['schemas']['LocationSearchResult']; +type FeedCounts = components['schemas']['LocationFeedCounts']; +type LocationsResponse = components['schemas']['LocationSearchResponse']; +type LocationDetail = components['schemas']['LocationDetail']; +interface FeedsResponse { + total: number; + results: Feed[]; +} + +type PlaceType = 'continent' | 'country' | 'subdivision' | 'municipality'; + +interface Place { + id: number; + parent: number | null; + type: PlaceType; + name: string; + /** + * Continent code, ISO 3166-1 (countries) or ISO 3166-2 (subdivisions) + * code. + */ + code?: string; +} + +/** Fixed ids, as in the API; Asia and South America have no feeds here. */ +const continent = (id: number, name: string, code: string): Place => ({ + id, + parent: null, + type: 'continent', + name, + code, +}); +const AFRICA = -1; +const EUROPE = -4; +const NORTH_AMERICA = -5; +const OCEANIA = -6; + +const country = ( + id: number, + name: string, + code: string, + continentId: number | null, +): Place => ({ id, parent: continentId, type: 'country', name, code }); +const subdivision = ( + id: number, + parent: number, + name: string, + code: string, +): Place => ({ id, parent, type: 'subdivision', name, code }); +const municipality = (id: number, parent: number, name: string): Place => ({ + id, + parent, + type: 'municipality', + name, +}); + +export const MOCK_PLACES: Place[] = [ + continent(AFRICA, 'Africa', 'AF'), + continent(-2, 'Antarctica', 'AN'), + continent(-3, 'Asia', 'AS'), + continent(EUROPE, 'Europe', 'EU'), + continent(NORTH_AMERICA, 'North America', 'NA'), + continent(OCEANIA, 'Oceania', 'OC'), + continent(-7, 'South America', 'SA'), + country(100, 'Canada', 'CA', NORTH_AMERICA), + subdivision(110, 100, 'Québec', 'CA-QC'), + municipality(111, 110, 'Montréal'), + municipality(112, 110, 'Laval'), + municipality(113, 110, 'Longueuil'), + municipality(114, 110, 'Québec'), + subdivision(120, 100, 'Ontario', 'CA-ON'), + municipality(121, 120, 'Toronto'), + subdivision(130, 100, 'British Columbia', 'CA-BC'), + municipality(131, 130, 'Vancouver'), + country(200, 'United States', 'US', NORTH_AMERICA), + subdivision(210, 200, 'California', 'US-CA'), + // Extra intermediate level: county > city. + municipality(211, 210, 'Los Angeles County'), + municipality(212, 211, 'Los Angeles'), + municipality(213, 211, 'Pasadena'), + municipality(214, 210, 'San Francisco'), + subdivision(220, 200, 'New York', 'US-NY'), + municipality(221, 220, 'New York'), + subdivision(230, 200, 'Ohio', 'US-OH'), + // Siblings sharing a name: both slugs get `-`. + municipality(123456, 230, 'Washington Township'), + municipality(123457, 230, 'Washington Township'), + country(300, 'France', 'FR', EUROPE), + subdivision(310, 300, 'Île-de-France', 'FR-IDF'), + municipality(311, 310, 'Paris'), + country(400, 'Germany', 'DE', EUROPE), + subdivision(410, 400, 'Berlin', 'DE-BE'), + municipality(411, 410, 'Berlin'), + country(500, 'Australia', 'AU', OCEANIA), + subdivision(510, 500, 'New South Wales', 'AU-NSW'), + municipality(511, 510, 'Sydney'), + country(600, 'South Africa', 'ZA', AFRICA), + subdivision(610, 600, 'Western Cape', 'ZA-WC'), + municipality(611, 610, 'Cape Town'), + // No continent in the classification: its path starts at the country. + country(700, 'Clipperton', 'CP', null), +]; + +const PLACES_BY_ID = new Map(MOCK_PLACES.map((place) => [place.id, place])); + +/** Same slug rule as the API: `Québec` -> `quebec`. */ +export function slugify(name: string): string { + return name + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-'); +} + +/** Slug among siblings, with `-` when a sibling shares it. */ +function slugOf(place: Place): string { + const slug = slugify(place.name); + const collides = MOCK_PLACES.some( + (other) => + other.id !== place.id && + other.parent === place.parent && + slugify(other.name) === slug, + ); + return collides ? `${slug}-${place.id}` : slug; +} + +/** The place and its ancestors, from the continent down. */ +function chainOf(place: Place): Place[] { + const chain: Place[] = []; + let current: Place | undefined = place; + while (current != undefined) { + chain.unshift(current); + current = + current.parent === null ? undefined : PLACES_BY_ID.get(current.parent); + } + return chain; +} + +function ancestorOfType(place: Place, type: PlaceType): Place | undefined { + return chainOf(place).find((ancestor) => ancestor.type === type); +} + +function isWithin(placeId: number, ancestorId: number): boolean { + const place = PLACES_BY_ID.get(placeId); + return ( + place != undefined && chainOf(place).some(({ id }) => id === ancestorId) + ); +} + +type FeedSeed = [ + dataType: Feed['data_type'], + provider: string, + feedName: string, + /** One entry per group of stops, all of equal weight. */ + stopPlaces: number[], +]; + +const MONTREAL = 111; +const EXO_SECTORS = [ + 'Chambly-Richelieu-Carignan', + 'Haut-Saint-Laurent', + 'Laurentides', + 'Le Richelain', + 'Roussillon', + 'Sorel-Varennes', + 'Sud-Ouest', + 'Terrebonne-Mascouche', + 'Vallée du Richelieu', + 'La Presqu’Île', + 'L’Assomption', + 'Deux-Montagnes', +]; + +const FEED_SEEDS: FeedSeed[] = [ + // Canada-wide + ['gtfs', 'Example Intercity Rail', 'National rail network', [110, 120, 130]], + // Québec, stops not geolocated to a municipality + ['gtfs', 'Example Coach Lines', 'Intercity coaches', [110]], + ['gtfs_rt', 'Example Coach Lines', 'Trip updates', [110]], + // Montréal: 22 feeds, more than one page + ['gtfs', 'Société de transport de Montréal', 'Bus and metro', [MONTREAL]], + ['gtfs_rt', 'Société de transport de Montréal', 'Trip updates', [MONTREAL]], + ['gtfs_rt', 'Société de transport de Montréal', 'Alerts', [MONTREAL]], + ['gtfs', 'Réseau express métropolitain', 'REM light metro', [MONTREAL]], + ['gtfs', 'exo', 'Commuter trains', [MONTREAL, 112, 113]], + ['gtfs_rt', 'exo', 'Commuter trains - trip updates', [MONTREAL]], + ...EXO_SECTORS.map( + (sector): FeedSeed => ['gtfs', 'exo', `Buses - ${sector}`, [MONTREAL]], + ), + ['gbfs', 'BIXI Montréal', 'Bike share', [MONTREAL]], + ['gbfs', 'Example Scooters', 'Montréal e-scooters', [MONTREAL]], + ['gbfs', 'Example Car Share', 'Montréal car share', [MONTREAL]], + ['gtfs_rt', 'exo', 'Commuter trains - vehicle positions', [MONTREAL]], + // Elsewhere in Québec + ['gtfs', 'Société de transport de Laval', 'Buses', [112]], + ['gtfs', 'Réseau de transport de Longueuil', 'Buses', [113]], + ['gtfs', 'Réseau de transport de la Capitale', 'Buses', [114]], + ['gtfs_rt', 'Réseau de transport de la Capitale', 'Vehicle positions', [114]], + // Ontario and British Columbia + ['gtfs', 'Example Regional Transit', 'Regional rail and buses', [120, 121]], + ['gtfs', 'Toronto Transit Commission', 'Surface and subway', [121]], + ['gtfs_rt', 'Toronto Transit Commission', 'Trip updates', [121]], + ['gbfs', 'Bike Share Toronto', 'Bike share', [121]], + ['gtfs', 'Example Coast Transit', 'Buses and SkyTrain', [131]], + // United States + ['gtfs', 'Example Metro Los Angeles', 'Bus', [212, 213]], + ['gtfs_rt', 'Example Metro Los Angeles', 'Trip updates', [212]], + ['gtfs', 'Example County Transit', 'County buses', [211]], + ['gtfs', 'Example Bay Area Rail', 'Rapid transit', [214]], + ['gbfs', 'Example Bikes SF', 'Bike share', [214]], + ['gtfs', 'Example Metropolitan Transit', 'Subway', [221]], + ['gtfs_rt', 'Example Metropolitan Transit', 'Subway trip updates', [221]], + ['gtfs', 'Example Township Shuttle', 'Shuttle', [123456]], + ['gtfs', 'Example Township Dial-a-Ride', 'Dial-a-ride', [123457]], + // Europe + ['gtfs', 'Example Île-de-France Mobilités', 'Regional network', [311]], + ['gbfs', 'Example Vélo Paris', 'Bike share', [311]], + ['gtfs', 'Example Verkehrsverbund Berlin', 'Regional network', [411]], + // Oceania + ['gtfs', 'Example Transport NSW', 'Sydney network', [511]], + ['gtfs_rt', 'Example Transport NSW', 'Trip updates', [511]], + // Africa + ['gtfs', 'Example Cape Town Bus', 'Bus rapid transit', [611]], + // No continent + ['gtfs', 'Example Island Boats', 'Supply boats', [700]], +]; + +interface MockFeed { + feed: Feed; + stopPlaces: number[]; +} + +function declaredLocation( + placeId: number, +): NonNullable[number] { + const place = PLACES_BY_ID.get(placeId) as Place; + const chain = chainOf(place); + const placeCountry = ancestorOfType(place, 'country'); + const municipalities = chain.filter(({ type }) => type === 'municipality'); + return { + country_code: placeCountry?.code, + country: placeCountry?.name, + subdivision_name: ancestorOfType(place, 'subdivision')?.name, + municipality: municipalities[municipalities.length - 1]?.name, + }; +} + +function toMockFeed( + [dataType, provider, feedName, stopPlaces]: FeedSeed, + index: number, +): MockFeed { + const id = `mock-loc-${String(index + 1).padStart(3, '0')}`; + return { + stopPlaces, + feed: { + id, + data_type: dataType, + status: 'active', + official: index % 3 === 0, + seasonal: false, + provider, + feed_name: feedName, + external_ids: [{ external_id: id, source: 'mdb' }], + source_info: { + producer_url: `https://example.com/${id}/${dataType}`, + authentication_type: 0, + }, + locations: stopPlaces.map(declaredLocation), + entity_types: dataType === 'gtfs_rt' ? ['tu', 'vp'] : undefined, + versions: dataType === 'gbfs' ? ['2.3'] : undefined, + }, + }; +} + +const MOCK_FEEDS: MockFeed[] = FEED_SEEDS.map(toMockFeed); + +/** Share of a feed's stop groups inside the location, 0 when outside. */ +function shareIn({ stopPlaces }: MockFeed, locationId: number): number { + const inside = stopPlaces.filter((placeId) => isWithin(placeId, locationId)); + return inside.length / stopPlaces.length; +} + +function feedsIn(locationId: number): MockFeed[] { + return MOCK_FEEDS.filter((feed) => shareIn(feed, locationId) > 0); +} + +function countFeeds(feeds: MockFeed[]): FeedCounts { + const count = (dataType: Feed['data_type']): number => + feeds.filter(({ feed }) => feed.data_type === dataType).length; + return { + gtfs: count('gtfs'), + gtfs_rt: count('gtfs_rt'), + gbfs: count('gbfs'), + total: feeds.length, + }; +} + +interface Row { + result: LocationResult; + depth: number; + counts: FeedCounts; +} + +function toRow(place: Place): Row { + const chain = chainOf(place); + const placeContinent = ancestorOfType(place, 'continent'); + const placeCountry = ancestorOfType(place, 'country'); + const placeSubdivision = ancestorOfType(place, 'subdivision'); + const pathSlugs = chain.map(slugOf); + // From the country down, or the continent itself. + const displayChain = + place.type === 'continent' + ? chain + : chain.filter(({ type }) => type !== 'continent'); + return { + depth: chain.length, + counts: countFeeds(feedsIn(place.id)), + result: { + location_id: place.id, + parent_location_id: place.parent, + name: place.name, + alt_name: null, + location_type: place.type, + continent_code: placeContinent?.code ?? null, + slug: slugOf(place), + path: pathSlugs.join('/'), + path_slugs: pathSlugs, + path_names: chain.map(({ name }) => name), + country_name: placeCountry?.name ?? null, + country_code: placeCountry?.code ?? null, + subdivision_name: placeSubdivision?.name ?? null, + subdivision_code: placeSubdivision?.code ?? null, + display_name: displayChain.map(({ name }) => name).join(', '), + }, + }; +} + +const ROWS: Row[] = MOCK_PLACES.map(toRow); +const ROWS_BY_ID = new Map( + ROWS.map((row) => [row.result.location_id as number, row]), +); + +export type MockResult = + | { status: 200; body: T } + | { status: 400 | 404; body: { detail: string } }; + +const badRequest = (detail: string): MockResult => ({ + status: 400, + body: { detail }, +}); + +const notFound = (detail: string): MockResult => ({ + status: 404, + body: { detail }, +}); + +function readPaging( + params: URLSearchParams, + maxLimit: number, +): { limit: number; offset: number } | null { + const limit = Number(params.get('limit') ?? String(maxLimit)); + const offset = Number(params.get('offset') ?? '0'); + if (!Number.isInteger(limit) || limit < 0 || limit > maxLimit) return null; + if (!Number.isInteger(offset) || offset < 0) return null; + return { limit, offset }; +} + +/** The comma-separated `include` values, or null when one is unsupported. */ +function readInclude( + params: URLSearchParams, + allowed: readonly string[], +): Set | null { + const values = (params.get('include') ?? '') + .split(',') + .filter((value) => value !== ''); + if (values.some((value) => !allowed.includes(value))) return null; + return new Set(values); +} + +function readSort( + params: URLSearchParams, +): 'admin_level' | 'feed_count' | null { + const sort = params.get('sort') ?? 'admin_level'; + return sort === 'admin_level' || sort === 'feed_count' ? sort : null; +} + +function readContinent(params: URLSearchParams): number | undefined { + const value = params.get('continent')?.toLowerCase(); + if (value == undefined || value === '') return undefined; + // Slug or code, case-insensitive; unknown values match nothing. + return ( + ROWS.find( + ({ result }) => + result.location_type === 'continent' && + [result.slug, result.continent_code?.toLowerCase()].includes(value), + )?.result.location_id ?? Number.NaN + ); +} + +const normalize = (text: string): string => + text + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase(); + +function sortRows(rows: Row[], sort: 'admin_level' | 'feed_count'): Row[] { + return [...rows].sort( + (a, b) => + (sort === 'feed_count' + ? (b.counts.total ?? 0) - (a.counts.total ?? 0) + : a.depth - b.depth) || + (a.result.display_name ?? '').localeCompare(b.result.display_name ?? ''), + ); +} + +/** + * Filters, sorts and pages rows like the list endpoints; `include` adds the + * feed counts. + */ +function listRows( + rows: Row[], + params: URLSearchParams, + include: Set, +): MockResult { + const sort = readSort(params); + if (sort === null) return badRequest('Unsupported sort.'); + const paging = readPaging(params, 100); + if (paging === null) return badRequest('Invalid limit or offset.'); + const hasFeeds = params.get('has_feeds') === 'true'; + const matching = sortRows( + rows.filter(({ counts }) => !hasFeeds || (counts.total ?? 0) > 0), + sort, + ); + return { + status: 200, + body: { + total: matching.length, + results: matching + .slice(paging.offset, paging.offset + paging.limit) + .map(({ result, counts }) => + include.has('feed_counts') + ? { ...result, feed_counts: counts } + : result, + ), + }, + }; +} + +/** GET /v1/locations */ +export function getMockLocations( + params: URLSearchParams, +): MockResult { + const include = readInclude(params, ['feed_counts']); + if (include === null) return badRequest('Unsupported include.'); + const locationType = params.get('location_type'); + const continentId = readContinent(params); + const countryCode = params.get('country_code')?.toUpperCase(); + const subdivisionCode = params.get('subdivision_code')?.toUpperCase(); + const words = normalize(params.get('search_query') ?? '') + .split(/\s+/) + .filter((word) => word !== ''); + + const rows = ROWS.filter(({ result }) => { + if (locationType != null && result.location_type !== locationType) + return false; + if ( + continentId != undefined && + !isWithin(result.location_id as number, continentId) + ) + return false; + if (countryCode != undefined && result.country_code !== countryCode) + return false; + if ( + subdivisionCode != undefined && + result.subdivision_code !== subdivisionCode + ) + return false; + const haystack = normalize(result.display_name ?? ''); + return words.every((word) => haystack.includes(word)); + }); + return listRows(rows, params, include); +} + +function childRows(locationId: number): Row[] { + return ROWS.filter(({ result }) => result.parent_location_id === locationId); +} + +/** The location of `row`, with its children when `include` has them. */ +function detailOf( + row: Row, + params: URLSearchParams, +): MockResult { + const include = readInclude(params, ['feed_counts', 'children']); + if (include === null) return badRequest('Unsupported include.'); + const location: LocationDetail = include.has('feed_counts') + ? { ...row.result, feed_counts: row.counts } + : { ...row.result }; + if (!include.has('children')) return { status: 200, body: location }; + const children = listRows( + childRows(row.result.location_id as number), + params, + include, + ); + if (children.status !== 200) return children; + return { status: 200, body: { ...location, children: children.body } }; +} + +function normalizePath(path: string): string { + return path.toLowerCase().replace(/^\/+|\/+$/g, ''); +} + +/** GET /v1/locations/lookup */ +export function getMockLocationByPath( + params: URLSearchParams, +): MockResult { + const path = normalizePath(params.get('path') ?? ''); + if (path === '') return badRequest('Missing path.'); + const row = ROWS.find(({ result }) => result.path === path); + if (row == undefined) return notFound(`No location at ${path}.`); + return detailOf(row, params); +} + +function readLocationId(raw: string): number | undefined { + const id = Number(raw); + return Number.isInteger(id) ? id : undefined; +} + +/** GET /v1/locations/{location_id} */ +export function getMockLocation( + rawId: string, + params: URLSearchParams, +): MockResult { + const id = readLocationId(rawId); + if (id === undefined) return badRequest('Invalid location_id.'); + const row = ROWS_BY_ID.get(id); + if (row == undefined) return notFound(`No location ${id}.`); + return detailOf(row, params); +} + +/** GET /v1/locations/{location_id}/children */ +export function getMockLocationChildren( + rawId: string, + params: URLSearchParams, +): MockResult { + const id = readLocationId(rawId); + if (id === undefined) return badRequest('Invalid location_id.'); + if (!ROWS_BY_ID.has(id)) return notFound(`No location ${id}.`); + const include = readInclude(params, ['feed_counts']); + if (include === null) return badRequest('Unsupported include.'); + return listRows(childRows(id), params, include); +} + +/** True when a /v1/search request targets a location. */ +export function isLocationSearch(params: URLSearchParams): boolean { + return ( + params.has('location_id') || + params.has('location_path') || + params.get('sort') === 'location' + ); +} + +/** GET /v1/search with `location_id` or `location_path`. */ +export function getMockLocationFeeds( + params: URLSearchParams, +): MockResult { + const sort = params.get('sort') ?? 'created_at'; + const rawLocationId = params.get('location_id'); + const rawPath = params.get('location_path'); + if (rawLocationId != null && rawPath != null) { + return badRequest('Pass location_id or location_path, not both.'); + } + let locationId: number | undefined; + if (rawLocationId != null) { + locationId = readLocationId(rawLocationId); + if (locationId === undefined) return badRequest('Invalid location_id.'); + } + if (sort !== 'created_at' && sort !== 'location') { + return badRequest('Unsupported sort.'); + } + if (sort === 'location' && rawLocationId == null && rawPath == null) { + return badRequest('sort=location requires location_id or location_path.'); + } + const paging = readPaging(params, 3500); + if (paging === null) return badRequest('Invalid limit or offset.'); + + if (rawPath != null) { + const path = normalizePath(rawPath); + locationId = ROWS.find(({ result }) => result.path === path)?.result + .location_id; + // Unknown values return no feed. + if (locationId == undefined) { + return { status: 200, body: { total: 0, results: [] } }; + } + } + + const scoped = + locationId == undefined + ? MOCK_FEEDS + : ROWS_BY_ID.has(locationId) + ? feedsIn(locationId) + : []; + const sorted = [...scoped].sort((a, b) => + sort === 'location' && locationId != undefined + ? shareIn(b, locationId) - shareIn(a, locationId) || + (a.feed.provider ?? '').localeCompare(b.feed.provider ?? '') || + (a.feed.feed_name ?? '').localeCompare(b.feed.feed_name ?? '') || + a.feed.id.localeCompare(b.feed.id) + : // Newest first: the mock ids grow with the creation order. + b.feed.id.localeCompare(a.feed.id), + ); + return { + status: 200, + body: { + total: sorted.length, + results: sorted + .slice(paging.offset, paging.offset + paging.limit) + .map(({ feed }) => feed), + }, + }; +}