diff --git a/app/common_test.ts b/app/common_test.ts index 0ed39373a29cc031e701c89ea0747fa34639102d..a876f7ac85ad57d6675b5f77dbc971140842ade4 100644 --- a/app/common_test.ts +++ b/app/common_test.ts @@ -3,14 +3,20 @@ import { GraphQLServer } from "./server.ts"; export type ResponsePayload = Record<string, unknown> | Array<unknown> | Error; -export const stubFetch = (response: ResponsePayload) => { +export const stubFetchWithResponses = (responses: ResponsePayload[]) => { return stub( globalThis, "fetch", - returnsNext([Promise.resolve(new Response(JSON.stringify(response)))]), + returnsNext( + responses.map((response) => Promise.resolve(new Response(JSON.stringify(response)))), + ), ); }; +export const stubFetch = (response: ResponsePayload) => { + return stubFetchWithResponses([response]); +}; + export const processGqlRequest = ( graphQLServer: GraphQLServer, query: string, diff --git a/app/dev_deps.ts b/app/dev_deps.ts index 59b65bea4e37c96d763fb21465fe0dc579d214e6..57be6074547d4791c63a4137fa2fb9c13a19599e 100644 --- a/app/dev_deps.ts +++ b/app/dev_deps.ts @@ -1,11 +1,12 @@ export { assertSpyCall, assertSpyCalls, + restore, returnsNext, type Spy, spy, + type Stub, stub, } from "https://deno.land/std@0.155.0/testing/mock.ts"; -export type { Stub } from "https://deno.land/std@0.155.0/testing/mock.ts"; export { assertEquals, assertExists, assertRejects } from "https://deno.land/std@0.155.0/testing/asserts.ts"; -export { afterEach, describe, it } from "https://deno.land/std@0.155.0/testing/bdd.ts"; +export { beforeEach, describe, it } from "https://deno.land/std@0.155.0/testing/bdd.ts"; diff --git a/app/geo_api_client.ts b/app/geo_api_client.ts index 6a14360331bbe471a0ccc2efced020d1e36a1a92..b847481a322e423777f943b64e361ed0bf5edc1d 100644 --- a/app/geo_api_client.ts +++ b/app/geo_api_client.ts @@ -1,16 +1,27 @@ -import { GeolocationCoordinates, GeolocationGeometry } from "./types.ts"; +import { GeolocationCoordinates, GeolocationGeometry, GeolocationProperties } from "./types.ts"; +import { logger } from "./logging.ts"; type GeoAPIResponse = { data?: { placeDetails?: { geolocation?: { - properties?: Partial<GeolocationCoordinates>; + properties?: Partial<GeolocationProperties>; geometry?: Partial<GeolocationGeometry>; }; }; }; }; +type GeoAPIPlace = { + id?: string; +}; + +type GeoAPIAutocompleteResponse = { + data?: { + placesAutocomplete?: GeoAPIPlace[]; + }; +}; + export class GeoAPIClient { private readonly geoAPIEndpointUrl: string; @@ -33,6 +44,21 @@ export class GeoAPIClient { return response.json() as GeoAPIResponse; } + private async resolvePlaceIdByName(name: string): Promise<string | undefined> { + const graphQLQuery = `{ placesAutocomplete(text: "${name}", limit: 1) { id } }`; + const response = await fetch(this.geoAPIEndpointUrl, { + "body": JSON.stringify({ query: graphQLQuery }), + "headers": { + "Accept": "application/graphql-response+json, application/json", + "Content-Type": "application/json", + }, + "method": "POST", + }); + const { data } = await response.json() as GeoAPIAutocompleteResponse; + const [place] = data?.placesAutocomplete || []; + return place?.id; + } + async resolveCoordinates( geolocationId: string, ): Promise<GeolocationCoordinates> { @@ -50,11 +76,22 @@ export class GeoAPIClient { async resolveGeometry( geolocationId: string, + { + allowPoints = false, + }: { allowPoints?: boolean } = {}, ): Promise<GeolocationGeometry> { const responseJSON = await this.resolveGeolocationViaGeoAPI(geolocationId); - const { type, coordinates } = responseJSON.data?.placeDetails?.geolocation - ?.geometry || {}; + const { geometry, properties } = responseJSON.data?.placeDetails?.geolocation || {}; + const { type, coordinates } = geometry || {}; if (type && coordinates) { + const locationName = properties?.formatted; + if (type === "Point" && !allowPoints && locationName) { + logger.debug(`Retrieved point as geolocation, will retry with lookup for location ${locationName}`); + const placeId = await this.resolvePlaceIdByName(locationName); + if (placeId && placeId !== geolocationId) { + return this.resolveGeometry(placeId, { allowPoints: true }); + } + } return { type, coordinates }; } else { return Promise.reject( diff --git a/app/geo_api_client_test.ts b/app/geo_api_client_test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8bb8c5412b64ceaf79274ab13726d0125d8c22e6 --- /dev/null +++ b/app/geo_api_client_test.ts @@ -0,0 +1,134 @@ +import { stubFetch, stubFetchWithResponses } from "./common_test.ts"; +import { assertRejects, restore } from "./dev_deps.ts"; +import { assertEquals, beforeEach, describe, it } from "./dev_deps.ts"; +import { GeoAPIClient } from "./geo_api_client.ts"; + +const testEndpointUrl = "https://endpoint.geo"; +const testPlaceId = "place123"; +const geometryMultipolygon = { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [8.1044993, 54.025049999], + [8.2109892, 53.980715499], + ], + ], + ], +}; +const geometryPoint = { + type: "Point", + coordinates: [10.000654, 53.550340999], +}; +const geolocationProperties = { + formatted: "Hamburg, Germany", +}; +const geometryResponseMultipolygon = { + data: { + placeDetails: { + geolocation: { + properties: geolocationProperties, + geometry: geometryMultipolygon, + }, + }, + }, +}; +const geometryResponsePoint = { + data: { + placeDetails: { + geolocation: { + properties: geolocationProperties, + geometry: geometryPoint, + }, + }, + }, +}; +const autocompleteResponse = { + data: { + placesAutocomplete: [{ + id: "place1234", + }], + }, +}; +const invalidResponse = {}; + +describe("GeoAPIClient", () => { + let client: GeoAPIClient; + + beforeEach(() => { + restore(); + client = new GeoAPIClient(testEndpointUrl); + }); + + describe("resolveGeometry", () => { + it("fetches geometry for place id", async () => { + stubFetch(geometryResponseMultipolygon); + + const response = await client.resolveGeometry(testPlaceId); + + assertEquals(response, geometryMultipolygon); + }); + + it("throws error for invalid response when fetching geometry", async () => { + stubFetch(invalidResponse); + + await assertRejects(() => client.resolveGeometry(testPlaceId)); + }); + + it("retries fetching geometry if geometry is only a point", async () => { + stubFetchWithResponses([geometryResponsePoint, autocompleteResponse, geometryResponseMultipolygon]); + + const response = await client.resolveGeometry(testPlaceId); + + assertEquals(response, geometryMultipolygon); + }); + + it("does not retry fetching geometry if points are allowed", async () => { + stubFetchWithResponses([geometryResponsePoint]); + + const response = await client.resolveGeometry(testPlaceId, { allowPoints: true }); + + assertEquals(response, geometryPoint); + }); + + it("only retries fetching geometry if geometry is only a point once", async () => { + stubFetchWithResponses([geometryResponsePoint, autocompleteResponse, geometryResponsePoint]); + + const response = await client.resolveGeometry(testPlaceId); + + assertEquals(response, geometryPoint); + }); + + it("only retries fetching geometry if autocomplete request results in a different place", async () => { + const autocompleteResponseSamePlaceId = [{ id: testPlaceId }]; + stubFetchWithResponses([geometryResponsePoint, autocompleteResponseSamePlaceId]); + + const response = await client.resolveGeometry(testPlaceId); + + assertEquals(response, geometryPoint); + }); + + it("handles empty autocomplete response when retrying fetching geometry", async () => { + const emptyAutocompleteResponse = { data: { placesAutocomplete: [] } }; + stubFetchWithResponses([geometryResponsePoint, emptyAutocompleteResponse]); + + const response = await client.resolveGeometry(testPlaceId); + + assertEquals(response, geometryPoint); + }); + + it("handles invalid autocomplete response when retrying fetching geometry", async () => { + stubFetchWithResponses([geometryResponsePoint, invalidResponse]); + + const response = await client.resolveGeometry(testPlaceId); + + assertEquals(response, geometryPoint); + }); + + it("throws error for invalid geometry response when retrying fetching geometry", async () => { + stubFetchWithResponses([geometryResponsePoint, autocompleteResponse, invalidResponse]); + + await assertRejects(() => client.resolveGeometry(testPlaceId)); + }); + }); +}); diff --git a/app/types.ts b/app/types.ts index 75530ad5b1219217cfe275fd51b79d36ed7a58d5..f81988af12ad5c2f9dbf39c899f654d89834e006 100644 --- a/app/types.ts +++ b/app/types.ts @@ -80,8 +80,12 @@ export type GeolocationCoordinates = { lon: number; }; +export type GeolocationProperties = { + formatted: string; +} & GeolocationCoordinates; + export type GeolocationGeoJSON = { - properties: GeolocationCoordinates; + properties: GeolocationProperties; geometry: GeolocationGeometry; }; diff --git a/app/voltastics_test.ts b/app/voltastics_test.ts index 9e53d22d02024833e27fc63e2c3e5452ba8256b5..4805306b1439b79a24f2dc727e68674d3260d277 100644 --- a/app/voltastics_test.ts +++ b/app/voltastics_test.ts @@ -1,8 +1,8 @@ import { - afterEach, assertEquals, assertRejects, assertSpyCall, + beforeEach, describe, it, returnsNext, @@ -94,7 +94,7 @@ const queryEngagement = async ( const queryCategories = async ( graphQLServer: GraphQLServer, ): Promise<CategoriesResponse> => { - const promise = processGqlRequest( + const response = await processGqlRequest( graphQLServer, ` query categories { @@ -107,7 +107,7 @@ const queryCategories = async ( {}, ); - return (await promise)?.categories as CategoriesResponse; + return response?.categories as CategoriesResponse; }; const voltasticsConfigMock: VoltasticsConfig = { @@ -156,7 +156,7 @@ const noCacheServerConfig = { describe("voltastics", () => { let fetchStub: Stub; - afterEach(() => { + beforeEach(() => { fetchStub?.restore(); });