perfect-postcode/frontend/src/lib/api.test.ts
2026-06-25 22:29:52 +01:00

239 lines
7.8 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { apiUrl, assertOk, buildFilterString, isAbortError, paramsWithLanguage } from './api';
import { createSchoolFilterKey } from './school-filter';
import { createSpecificCrimeFilterKey } from './crime-filter';
import { createElectionVoteShareFilterKey } from './election-filter';
import { createEthnicityFilterKey } from './ethnicity-filter';
import { createQualificationFilterKey } from './qualification-filter';
import { createTenureFilterKey } from './tenure-filter';
import {
POI_COUNT_2KM_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
createPoiDistanceFilterKey,
createPoiFilterKey,
} from './poi-distance-filter';
describe('api utilities', () => {
it('builds API URLs from endpoint names, paths, and params', () => {
expect(apiUrl('features')).toBe('/api/features');
expect(apiUrl('/custom/path')).toBe('/custom/path');
expect(apiUrl('hexagons', new URLSearchParams({ bounds: '1,2,3,4' }))).toBe(
'/api/hexagons?bounds=1%2C2%2C3%2C4'
);
});
it('throws helpful errors for non-OK responses', () => {
expect(() => assertOk(new Response(null, { status: 204 }), 'empty')).not.toThrow();
expect(() =>
assertOk(new Response(null, { status: 404, statusText: 'Not Found' }), 'lookup')
).toThrow('lookup: HTTP 404 Not Found');
});
it('recognizes AbortError instances', () => {
const abort = new Error('Aborted');
abort.name = 'AbortError';
const regular = new Error('nope');
expect(isAbortError(abort)).toBe(true);
expect(isAbortError(regular)).toBe(false);
});
it('adds supported language parameters without overriding explicit languages', () => {
expect(paramsWithLanguage('lat=51.5&lon=-0.1', 'fr-FR')).toBe('lat=51.5&lon=-0.1&lang=fr');
expect(paramsWithLanguage('lat=51.5&lang=de', 'fr')).toBe('lat=51.5&lang=de');
});
it('serializes numeric, absolute, and enum filters for backend routes', () => {
const features: FeatureMeta[] = [
{ name: 'Last known price', type: 'numeric', min: 0, max: 1_000_000 },
{
name: 'Estimated current price',
type: 'numeric',
absolute: true,
histogram: { min: 0, max: 2_000_000, p1: 0, p99: 2_000_000, counts: [1] },
},
{ name: 'Property type', type: 'enum', values: ['Flat', 'House'] },
];
expect(
buildFilterString(
{
'Last known price': [100_000, 500_000],
'Estimated current price': [0, 2_000_000],
'Property type': ['Flat', 'House'],
},
features
)
).toBe(
'Last known price:100000:500000;;Estimated current price:0:inf;;Property type:Flat|House'
);
expect(
buildFilterString(
{
'Last known price': [100_000, 500_000],
'Property type': ['Flat'],
},
features,
'Last known price'
)
).toBe('Property type:Flat');
});
it('deduplicates repeated synthetic school filters before backend routes', () => {
const features: FeatureMeta[] = [
{ name: 'Good+ primary school catchments', type: 'numeric', min: 0, max: 10 },
];
expect(
buildFilterString(
{
[createSchoolFilterKey('primary', 'good', 1)]: [1, 10],
[createSchoolFilterKey('primary', 'good', 2)]: [2, 8],
},
features
)
).toBe('Good+ primary school catchments:2:8');
});
it('serializes specific crime filters using their selected backend crime feature', () => {
const features: FeatureMeta[] = [
{ name: 'Burglary (/yr, 7y)', type: 'numeric', min: 0, max: 20 },
{ name: 'Vehicle crime (/yr, 7y)', type: 'numeric', min: 0, max: 30 },
];
expect(
buildFilterString(
{
[createSpecificCrimeFilterKey('Burglary (/yr, 7y)', 1)]: [0, 5],
[createSpecificCrimeFilterKey('Vehicle crime (/yr, 7y)', 2)]: [1, 10],
},
features
)
).toBe('Burglary (/yr, 7y):0:5;;Vehicle crime (/yr, 7y):1:10');
});
it('serializes election vote-share filters using their selected backend party feature', () => {
const features: FeatureMeta[] = [
{ name: '% Labour', type: 'numeric', min: 0, max: 100 },
{ name: '% Conservative', type: 'numeric', min: 0, max: 100 },
];
expect(
buildFilterString(
{
[createElectionVoteShareFilterKey('% Labour', 1)]: [30, 60],
[createElectionVoteShareFilterKey('% Conservative', 2)]: [10, 40],
},
features
)
).toBe('% Labour:30:60;;% Conservative:10:40');
});
it('deduplicates repeated ethnicity filters to the strictest backend range', () => {
const features: FeatureMeta[] = [{ name: '% White', type: 'numeric', min: 0, max: 100 }];
expect(
buildFilterString(
{
[createEthnicityFilterKey('% White', 1)]: [10, 90],
[createEthnicityFilterKey('% White', 2)]: [20, 80],
},
features
)
).toBe('% White:20:80');
});
it('serializes qualification filters using their selected backend band feature', () => {
const features: FeatureMeta[] = [
{ name: '% Degree or higher', type: 'numeric', min: 0, max: 100 },
{ name: '% No qualifications', type: 'numeric', min: 0, max: 100 },
];
expect(
buildFilterString(
{
[createQualificationFilterKey('% Degree or higher', 1)]: [20, 60],
[createQualificationFilterKey('% No qualifications', 2)]: [0, 25],
},
features
)
).toBe('% Degree or higher:20:60;;% No qualifications:0:25');
});
it('serializes tenure filters using their selected backend band feature', () => {
const features: FeatureMeta[] = [
{ name: '% Owner occupied', type: 'numeric', min: 0, max: 100 },
{ name: '% Private rent', type: 'numeric', min: 0, max: 100 },
];
expect(
buildFilterString(
{
[createTenureFilterKey('% Owner occupied', 1)]: [20, 60],
[createTenureFilterKey('% Private rent', 2)]: [0, 25],
},
features
)
).toBe('% Owner occupied:20:60;;% Private rent:0:25');
});
it('serializes amenity distance filters using their selected backend feature', () => {
const features: FeatureMeta[] = [
{ name: 'Distance to nearest amenity (Park) (km)', type: 'numeric', min: 0, max: 2 },
{ name: 'Distance to nearest amenity (Café) (km)', type: 'numeric', min: 0, max: 5 },
];
expect(
buildFilterString(
{
[createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 1)]: [0, 0.5],
[createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 2)]: [0, 1],
},
features
)
).toBe(
'Distance to nearest amenity (Park) (km):0:0.5;;Distance to nearest amenity (Café) (km):0:1'
);
});
it('serializes amenity count filters using their selected backend feature', () => {
const features: FeatureMeta[] = [
{ name: 'Number of amenities (Cafe) within 2km', type: 'numeric', min: 0, max: 20 },
];
expect(
buildFilterString(
{
[createPoiFilterKey(
POI_COUNT_2KM_FILTER_NAME,
'Number of amenities (Cafe) within 2km',
1
)]: [2, 10],
},
features
)
).toBe('Number of amenities (Cafe) within 2km:2:10');
});
it('serializes transport distance filters using their selected backend feature', () => {
const features: FeatureMeta[] = [
{ name: 'Distance to nearest amenity (Bus stop) (km)', type: 'numeric', min: 0, max: 2 },
];
expect(
buildFilterString(
{
[createPoiFilterKey(
TRANSPORT_DISTANCE_FILTER_NAME,
'Distance to nearest amenity (Bus stop) (km)',
1
)]: [0, 0.4],
},
features
)
).toBe('Distance to nearest amenity (Bus stop) (km):0:0.4');
});
});