Merged in feature/SW-3572-hotel-data-endpoint (pull request #3051)

SW-3572 API route for listing hotels per city or country

* wip hotel data endpoint

* Correct route params type

* wip

* skip static paths call

* timeout when getting destinations take too long

* call noStore when we get a timeout

* add cache-control headers

* .

* .

* .

* wip

* wip

* wip

* wip

* add route for getting hotels per country

* include city when listing by country

* fix distance SI unit

* fix sorting

* Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/SW-3572-hotel-data-endpoint

* packages/tracking passWithNoTests

* revalidate must be static value

* remove oxc reference

* cleanup

* cleanup hotel api route

* feat(SW-3572): cleanup error handling


Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-11-03 12:10:22 +00:00
parent e8626d56af
commit 15a2da333d
25 changed files with 1227 additions and 249 deletions

View File

@@ -1,7 +1,8 @@
import { Lang } from "@scandic-hotels/common/constants/language"
import { getLocations } from "../utils"
import { isCityLocation } from "../../../types/locations"
import { getHotelIdsByCityId } from "./getHotelIdsByCityId"
import { getLocationsByCountries } from "./getLocationsByCountries"
export async function getCityByCityIdentifier({
cityIdentifier,
@@ -12,17 +13,18 @@ export async function getCityByCityIdentifier({
lang: Lang
serviceToken: string
}) {
const locations = await getLocations({
const locations = await getLocationsByCountries({
lang,
citiesByCountry: null,
serviceToken,
})
if (!locations || "error" in locations) {
if (!locations || locations.length === 0) {
return null
}
const city = locations
.filter((loc) => loc.type === "cities")
.filter(isCityLocation)
.find((loc) => loc.cityIdentifier === cityIdentifier)
return city ?? null
@@ -46,5 +48,6 @@ export async function getHotelIdsByCityIdentifier(
cityId: city.id,
serviceToken,
})
return hotelIds
}

View File

@@ -0,0 +1,398 @@
import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"
import { Lang } from "@scandic-hotels/common/constants/language"
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import * as api from "../../../api"
import { getCity } from "./getCity"
import { getLocationsByCountries } from "./getLocationsByCountries"
import type { CitiesGroupedByCountry } from "../../../types/locations"
// Mocks
vi.mock("@scandic-hotels/common/dataCache", () => {
return {
getCacheClient: vi.fn(),
}
})
vi.mock("../../../api", () => {
return {
get: vi.fn(),
endpoints: {
v1: {
Hotel: {
locations: "/locations",
},
},
},
}
})
vi.mock("./getCity", () => {
return {
getCity: vi.fn(),
}
})
vi.mock("@scandic-hotels/common/logger/createLogger", () => {
return {
createLogger: () => ({
error: vi.fn(),
info: vi.fn(),
}),
}
})
const mockedGetCacheClient = getCacheClient as unknown as Mock
const mockedApiGet = api.get as unknown as Mock
const mockedGetCity = getCity as unknown as Mock
describe("getLocationsByCountries", () => {
const mockedCacheClient = {
cacheOrGet: vi.fn().mockImplementation(async (_key: string, cb: any) => {
return cb()
}),
}
beforeEach(() => {
vi.clearAllMocks()
})
it("returns cached value when cache has data", async () => {
const cacheClient = {
cacheOrGet: vi.fn().mockResolvedValueOnce("CACHED_VALUE"),
}
mockedGetCacheClient.mockResolvedValueOnce(cacheClient)
const result = await getLocationsByCountries({
lang: Lang.en,
citiesByCountry: null,
serviceToken: "token",
} as any)
expect(result).toBe("CACHED_VALUE")
expect(cacheClient.cacheOrGet).toHaveBeenCalled()
expect(mockedApiGet).not.toHaveBeenCalled()
})
it("throws unauthorized on 401 response", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
mockedApiGet.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({}),
})
await expect(
getLocationsByCountries({
lang: Lang.en,
citiesByCountry: null,
serviceToken: "token",
})
).rejects.toThrow("Unauthorized")
expect(mockedApiGet).toHaveBeenCalled()
})
it("throws forbidden on 403 response", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
mockedApiGet.mockResolvedValueOnce({
ok: false,
status: 403,
json: async () => ({}),
})
await expect(
getLocationsByCountries({
lang: Lang.en,
citiesByCountry: null,
serviceToken: "token",
})
).rejects.toThrow("Forbidden")
})
it("parses locations and enriches city country and hotel city via getCity", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
const apiPayload = mockApiData({
numberOfCities: 1,
numberOfHotels: 1,
})
mockedApiGet.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => apiPayload,
})
// getCity returns enriched city object for hotel relationship
const mockedCity: Awaited<ReturnType<typeof getCity>> = {
cityIdentifier: "remote-ci-1",
ianaTimeZoneId: "Europe/Stockholm",
id: "remote-city-id",
isPublished: true,
keywords: [],
name: "RemoteCity",
timeZoneId: "Europe/Stockholm",
type: "cities",
}
mockedGetCity.mockResolvedValueOnce(mockedCity)
const citiesByCountry = {
CountryX: [{ name: "CityAA" }],
} as unknown as CitiesGroupedByCountry
const result = await getLocationsByCountries({
lang: Lang.en,
citiesByCountry,
serviceToken: "token",
})
// Result should be an array with two entries
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(2)
const cityNode = result
.filter((n) => n.type === "cities")
.find((n) => n.name === "City1")
const hotelNode = result
.filter((n) => n.type === "hotels")
.find((n) => n.name === "Hotel1")
expect(cityNode).toBeDefined()
expect(cityNode!.country).toBe("CountryX") // country assigned based on citiesByCountry
expect(hotelNode).toBeDefined()
expect(mockedGetCity).toHaveBeenCalledWith({
cityUrl: "https://api/cities/city1",
serviceToken: "token",
})
// hotel relationships.city should be the object returned by getCity (merged)
expect(hotelNode?.relationships).toBeDefined()
expect(hotelNode?.relationships.city).toEqual(
expect.objectContaining({
id: mockedCity.id,
name: mockedCity.name,
})
)
})
it("parses locations and enriches city country and hotel city via getCity", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
const apiPayload = mockApiData({
numberOfCities: 2,
numberOfHotels: 2,
})
mockedApiGet.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => apiPayload,
})
// getCity returns enriched city object for hotel relationship
const mockedCity: Awaited<ReturnType<typeof getCity>> = {
cityIdentifier: "remote-ci-1",
ianaTimeZoneId: "Europe/Stockholm",
id: "remote-city-id",
isPublished: true,
keywords: [],
name: "RemoteCity",
timeZoneId: "Europe/Stockholm",
type: "cities",
}
mockedGetCity.mockResolvedValue(mockedCity)
const citiesByCountry = {
CountryX: [{ name: "CityAA" }],
} as unknown as CitiesGroupedByCountry
const result = await getLocationsByCountries({
lang: Lang.en,
citiesByCountry,
serviceToken: "token",
})
// Result should be an array with two entries
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(4)
const city1 = result
.filter((n) => n.type === "cities")
.find((n) => n.name === "City1")
expect(city1).toBeDefined()
expect(city1?.country).toBe("CountryX")
const hotel1 = result
.filter((n) => n.type === "hotels")
.find((n) => n.name === "Hotel1")
expect(hotel1).toBeDefined()
expect(mockedGetCity).toHaveBeenCalledWith({
cityUrl: "https://api/cities/city1",
serviceToken: "token",
})
// hotel relationships.city should be the object returned by getCity (merged)
expect(hotel1?.relationships).toBeDefined()
expect(hotel1?.relationships.city).toEqual(
expect.objectContaining({
id: mockedCity.id,
name: mockedCity.name,
})
)
const hotel2 = result
.filter((n) => n.type === "hotels")
.find((n) => n.name === "Hotel2")
expect(hotel2).toBeDefined()
expect(mockedGetCity).toHaveBeenCalledWith({
cityUrl: "https://api/cities/city2",
serviceToken: "token",
})
// hotel relationships.city should be the object returned by getCity (merged)
expect(hotel2?.relationships).toBeDefined()
expect(hotel2?.relationships.city).toEqual(
expect.objectContaining({
id: mockedCity.id,
name: mockedCity.name,
})
)
expect(mockedGetCity).toHaveBeenCalledTimes(2)
})
it("filters out unpublished cities", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
const apiPayload = mockApiData({
numberOfCities: 2,
numberOfHotels: 2,
})
apiPayload.data[0].attributes.isPublished = false
mockedApiGet.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => apiPayload,
})
const citiesByCountry = {
CountryX: [{ name: "CityAA" }],
} as unknown as CitiesGroupedByCountry
const result = await getLocationsByCountries({
lang: Lang.en,
citiesByCountry,
serviceToken: "token",
})
// Result should be an array with two entries
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(3)
const city1 = result
.filter((n) => n.type === "cities")
.find((n) => n.name === "City1")
expect(city1).toBeUndefined()
const hotel1 = result
.filter((n) => n.type === "hotels")
.find((n) => n.name === "Hotel1")
expect(hotel1).toBeDefined()
const hotel2 = result
.filter((n) => n.type === "hotels")
.find((n) => n.name === "Hotel2")
expect(hotel2).toBeDefined()
})
it("sorts the result with cities first", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
const apiPayload = mockApiData({
numberOfCities: 9,
numberOfHotels: 9,
})
apiPayload.data = apiPayload.data.sort(() => Math.random() - 0.5) // shuffle
mockedApiGet.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => apiPayload,
})
const citiesByCountry = {
CountryX: [{ name: "CityAA" }],
} as unknown as CitiesGroupedByCountry
const result = await getLocationsByCountries({
lang: Lang.en,
citiesByCountry,
serviceToken: "token",
})
expect(result.at(0)?.type).toBe("cities")
expect(result.at(-1)?.type).toBe("hotels")
expect(result[0].type === "cities" ? result[0].name : undefined).toBe(
"City1"
)
expect(result[1].type === "cities" ? result[1].name : undefined).toBe(
"City2"
)
expect(result[9].type === "hotels" ? result[9].name : undefined).toBe(
"Hotel1"
)
expect(result[10].type === "hotels" ? result[10].name : undefined).toBe(
"Hotel2"
)
})
})
function mockApiData({
numberOfCities,
numberOfHotels,
}: {
numberOfCities: number
numberOfHotels: number
}) {
const cities = Array.from({ length: numberOfCities }, (_, i) => ({
id: `city${i + 1}`,
type: "cities" as const,
attributes: {
name: `City${i + 1}`,
countryName: `CountryX`,
cityIdentifier: `ci-${i + 1}`,
isPublished: true,
},
}))
const hotels = Array.from({ length: numberOfHotels }, (_, i) => ({
id: `hotel${i + 1}`,
type: "hotels" as const,
attributes: {
isActive: true,
name: `Hotel${i + 1}`,
operaId: `op-${i + 1}`,
isPublished: true,
},
relationships: {
city: {
links: {
related: `https://api/cities/city${i + 1}`,
},
},
},
}))
const apiPayload = {
data: [...cities, ...hotels],
}
return apiPayload
}

View File

@@ -0,0 +1,226 @@
import deepmerge from "deepmerge"
import { z } from "zod"
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
import { chunk } from "@scandic-hotels/common/utils/chunk"
import * as api from "../../../api"
import { serverErrorByStatus } from "../../../errors"
import { toApiLang } from "../../../utils"
import { locationCitySchema } from "../schemas/location/city"
import { locationHotelSchema } from "../schemas/location/hotel"
import { getCity } from "./getCity"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { Country } from "../../../types/country"
const hotelUtilsLogger = createLogger("getLocationsByCountries")
type CitiesNamesByCountry = Record<
Country | (string & {}),
Array<{ name: string }>
> | null
export async function getLocationsByCountries({
lang,
citiesByCountry,
serviceToken,
}: {
lang: Lang
citiesByCountry: CitiesNamesByCountry | null
serviceToken: string
}) {
const cacheClient = await getCacheClient()
const countryKeys = Object.keys(citiesByCountry ?? {})
let cacheKey = `${lang}:locations`
if (countryKeys.length > 0) {
cacheKey += `:${countryKeys.toSorted().join(",")}`
}
return await cacheClient.cacheOrGet(
cacheKey.toLowerCase(),
async () => {
const apiResponse = await api.get(
api.endpoints.v1.Hotel.locations,
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
{
language: toApiLang(lang),
}
)
if (!apiResponse.ok) {
throw serverErrorByStatus(apiResponse.status, { apiResponse })
}
const apiJson = await apiResponse.json()
const verifiedLocations = locationsSchema.safeParse(apiJson)
if (!verifiedLocations.success) {
hotelUtilsLogger.error(
`Locations Verification Failed`,
verifiedLocations.error
)
throw new Error("Unable to parse api response for locations", {
cause: verifiedLocations.error,
})
}
const data = cleanData(verifiedLocations.data.data)
const cities = data
.filter((x) => x.type === "cities")
.map((x) => enrichCity(x, citiesByCountry))
const chunkedHotels = chunk(
data.filter((x) => x.type === "hotels"),
10
)
const hotels = (
await Promise.all(
chunkedHotels.flatMap(async (chunk) => {
return await Promise.all(
chunk.flatMap(async (hotel) => enrichHotel(hotel, serviceToken))
)
})
)
).flat()
let locations: z.infer<typeof locationsSchema>["data"] = [
...cities,
...hotels,
]
return locations
},
"1d"
)
}
async function enrichHotel(
hotel: Extract<
z.infer<typeof locationsSchema>["data"][number],
{ type: "hotels" }
>,
serviceToken: string
): Promise<
Extract<z.infer<typeof locationsSchema>["data"][number], { type: "hotels" }>
> {
if (hotel.type !== "hotels") {
return hotel
}
if (!hotel.relationships.city?.url) {
return hotel
}
const city = await getCity({
cityUrl: hotel.relationships.city.url,
serviceToken,
})
if (!city) {
return hotel
}
return deepmerge(hotel, {
relationships: {
city,
},
})
}
function enrichCity(
city: Extract<
z.infer<typeof locationsSchema>["data"][number],
{ type: "cities" }
>,
citiesByCountry: CitiesNamesByCountry | null
): Extract<
z.infer<typeof locationsSchema>["data"][number],
{ type: "cities" }
> {
if (!citiesByCountry) {
return city
}
const country = Object.keys(citiesByCountry).find((country) =>
citiesByCountry[country].find((loc) => loc.name === city.name)
)
if (!country) {
hotelUtilsLogger.error(
`Location cannot be found in any of the countries cities`,
city
)
return city
}
return {
...city,
country,
}
}
function cleanData(data: z.infer<typeof locationsSchema>["data"]) {
return data
.filter((node) => {
if (node?.isPublished !== true) {
return false
}
if (node.type === "hotels" && !node.operaId) {
return false
}
if (node.type === "cities" && !node.cityIdentifier) {
return false
}
return true
})
.toSorted((a, b) => {
if (a.type === b.type) {
return a.name.localeCompare(b.name)
} else {
return a.type === "cities" ? -1 : 1
}
})
}
export const locationsSchema = z.object({
data: z.array(
z
.discriminatedUnion("type", [locationCitySchema, locationHotelSchema])
.transform((location) => {
if (location.type === "cities") {
return {
...location.attributes,
country: location.attributes.countryName || "",
id: location.id,
type: location.type,
}
}
return {
...location.attributes,
id: location.id,
relationships: {
city: {
cityIdentifier: "",
ianaTimeZoneId: "",
id: "",
isPublished: false,
keywords: [],
name: "",
timeZoneId: "",
type: "cities",
url: location?.relationships?.city?.links?.related ?? "",
},
},
type: location.type,
operaId: location.attributes.operaId ?? "",
}
})
),
})