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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user