fix(SW-3442) getLowestRoomPrice - cannot read property of undefined

* fix: getLowestRoomPrice throws when given unexpected data
* dont track lowestRoomPrice if unavailable


Approved-by: Hrishikesh Vaipurkar
This commit is contained in:
Joakim Jäderberg
2025-10-03 13:16:25 +00:00
parent 5bf18f0412
commit 9292c437f4
8 changed files with 312 additions and 49 deletions

View File

@@ -0,0 +1 @@
TZ=UTC

View File

@@ -36,7 +36,7 @@ export function RoomsContainer({}: RoomsContainerProps) {
const lowestRoomPrice = getLowestRoomPrice() const lowestRoomPrice = getLowestRoomPrice()
const booking = inputData?.booking const booking = inputData?.booking
if (!booking) return if (!booking || !lowestRoomPrice) return
trackLowestRoomPrice({ trackLowestRoomPrice({
hotelId: booking.hotelId, hotelId: booking.hotelId,

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest"
import { calculateNumberOfNights } from "./calculateNumberOfNights"
describe("calculateNumberOfNights", () => {
it("returns 0 when fromDate is undefined", () => {
expect(calculateNumberOfNights(undefined, "2023-01-02")).toBe(0)
})
it("returns 0 when toDate is undefined", () => {
expect(calculateNumberOfNights("2023-01-01", undefined)).toBe(0)
})
it("returns 0 for the same date (string)", () => {
expect(calculateNumberOfNights("2023-01-01", "2023-01-01")).toBe(0)
})
it("calculates nights for string date inputs", () => {
expect(calculateNumberOfNights("2023-01-01", "2023-01-05")).toBe(4)
})
it("calculates nights for Date object inputs", () => {
expect(
calculateNumberOfNights(
new Date("2023-02-10T00:00:00Z"),
new Date("2023-02-12T00:00:00Z")
)
).toBe(2)
})
it("handles mixed Date and string inputs", () => {
expect(calculateNumberOfNights(new Date("2023-03-01"), "2023-03-04")).toBe(
3
)
})
it("returns a negative number when fromDate is after toDate", () => {
expect(calculateNumberOfNights("2023-01-05", "2023-01-01")).toBe(-4)
})
})

View File

@@ -0,0 +1,10 @@
import { dt } from "@scandic-hotels/common/dt"
export function calculateNumberOfNights(
fromDate: string | Date | undefined,
toDate: string | Date | undefined
): number {
if (!fromDate || !toDate) return 0
return dt(toDate).diff(dt(fromDate), "day")
}

View File

@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { getLowestRoomPrice } from "./getLowestRoomPrice"
import type { AvailabilityWithRoomInfo } from "../types"
describe("getLowestRoomPrice", () => {
it("returns null when roomAvailability is empty", () => {
const res = getLowestRoomPrice([])
expect(res).toBeNull()
})
it("returns null when first row has no rooms (only nulls)", () => {
const roomAvailability = [[null, null], [{ products: [] }]]
const res = getLowestRoomPrice(
roomAvailability as (AvailabilityWithRoomInfo | null)[][]
)
expect(res).toBeNull()
})
it("returns undefined when first room has no price products", () => {
const mixedProduct = {
public: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 50 },
},
}
const firstRoom = { products: [mixedProduct] }
const roomAvailability = [
[
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ brokenData: 1 } as any,
firstRoom,
],
]
const res = getLowestRoomPrice(roomAvailability)
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 50 })
})
it("returns the first price product converted to { currency, price }", () => {
const publicProduct = {
public: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 1000 },
},
}
const memberProduct = {
member: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 900 },
},
}
const firstRoom = { products: [publicProduct, memberProduct] }
const roomAvailability = [[firstRoom], [{ products: [] }]]
const res = getLowestRoomPrice(
roomAvailability as unknown as (AvailabilityWithRoomInfo | null)[][]
)
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 900 })
})
it("prefers member/public values from the product object when present", () => {
// product with both member and public: member values should be used for currency/price resolution
const mixedProduct = {
public: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 1000 },
},
member: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 800 },
},
}
const firstRoom = { products: [mixedProduct] }
const roomAvailability = [[null, firstRoom]]
const res = getLowestRoomPrice(
roomAvailability as (AvailabilityWithRoomInfo | null)[][]
)
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 800 })
})
it("prefers member/public values from the product object when present", () => {
// product with both member and public: member values should be used for currency/price resolution
const mixedProduct = {
public: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 1200 },
},
member: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 800 },
},
}
const firstRate = { products: [mixedProduct] }
const firstRoom = [null, firstRate]
const res = getLowestRoomPrice([
firstRoom,
] as (AvailabilityWithRoomInfo | null)[][])
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 800 })
})
it("gets the lowest price even if the order is mixed", () => {
const firstRate = {
products: [
{
public: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 1200 },
},
member: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 800 },
},
},
],
}
const secondRate = {
products: [
{
public: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 1000 },
},
member: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 700 },
},
},
],
}
const firstRoom = [firstRate, secondRate]
const secondRoom = [null, firstRate, secondRate]
const roomAvailability = [
null,
firstRoom,
secondRoom,
] as (AvailabilityWithRoomInfo | null)[][]
const res = getLowestRoomPrice(roomAvailability)
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 700 })
})
it("gets the lowest price even if the public price is lower than member price", () => {
const firstRate = {
products: [
{
public: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 700 },
},
member: {
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 800 },
},
},
],
}
const firstRoom = [firstRate]
const roomAvailability = [
firstRoom,
] as (AvailabilityWithRoomInfo | null)[][]
const res = getLowestRoomPrice(roomAvailability)
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 700 })
})
})

View File

@@ -0,0 +1,90 @@
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import type {
CorporateChequeProduct,
PriceProduct,
RedemptionProduct,
VoucherProduct,
} from "@scandic-hotels/trpc/types/roomAvailability"
import type { AvailabilityWithRoomInfo } from "../types"
export function getLowestRoomPrice(
roomAvailability: (AvailabilityWithRoomInfo | null)[][]
): { currency: CurrencyEnum; price: number } | null {
const products = roomAvailability
?.flatMap((room) => room?.flatMap((rates) => rates?.products))
.filter((x) => !!x)
if (!products || !products.length) {
return null
}
const sorted = sortRates(products as PriceInfo[])
return sorted.at(0) ?? null
}
type PriceInfo =
| Pick<PriceProduct, "member" | "public">
| Pick<RedemptionProduct, "redemption">
| Pick<CorporateChequeProduct, "corporateCheque">
| Pick<VoucherProduct, "voucher">
function sortRates(rates: PriceInfo[]) {
const mapped = rates.map((rate) => getPriceFromProduct(rate))
return mapped
.filter((x) => !!x)
.toSorted((a, b) => {
return a.price - b.price
})
}
function getPriceFromProduct(product: PriceInfo): {
currency: CurrencyEnum
price: number
} | null {
if (
("public" in product && product.public) ||
("member" in product && product.member)
) {
if (!product.public && !product.member) {
return null
}
const minPrice = Math.min(
product.member?.localPrice.pricePerNight || Infinity,
product.public?.localPrice.pricePerNight || Infinity
)
return {
currency:
(product.member?.localPrice.currency as CurrencyEnum) ||
(product.public?.localPrice.currency as CurrencyEnum) ||
CurrencyEnum.Unknown,
price: minPrice === Infinity ? 0 : minPrice,
}
}
if ("voucher" in product) {
return {
currency: CurrencyEnum.Voucher,
price: product.voucher?.numberOfVouchers,
}
}
if ("corporateCheque" in product) {
return {
currency: CurrencyEnum.CC,
price: product.corporateCheque?.localPrice.numberOfCheques,
}
}
if ("redemption" in product) {
return {
currency: CurrencyEnum.POINTS,
price: product.redemption.localPrice.pointsPerNight,
}
}
return null
}

View File

@@ -12,34 +12,34 @@ import {
import { type IntlShape, useIntl } from "react-intl" import { type IntlShape, useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency" import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { dt } from "@scandic-hotels/common/dt"
import { logger } from "@scandic-hotels/common/logger" import { logger } from "@scandic-hotels/common/logger"
import { type RouterOutput, trpc } from "@scandic-hotels/trpc/client" import { type RouterOutput, trpc } from "@scandic-hotels/trpc/client"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter" import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel" import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
import { selectRateRoomsAvailabilityInputSchema } from "@scandic-hotels/trpc/routers/hotels/availability/selectRate/rooms/schema" import { selectRateRoomsAvailabilityInputSchema } from "@scandic-hotels/trpc/routers/hotels/availability/selectRate/rooms/schema"
import { useIsLoggedIn } from "../../hooks/useIsLoggedIn" import { useIsLoggedIn } from "../../../hooks/useIsLoggedIn"
import useLang from "../../hooks/useLang" import useLang from "../../../hooks/useLang"
import { BookingCodeFilterEnum } from "../../stores/bookingCode-filter" import { BookingCodeFilterEnum } from "../../../stores/bookingCode-filter"
import { import {
parseSelectRateSearchParams, parseSelectRateSearchParams,
searchParamsToRecord, searchParamsToRecord,
serializeBookingSearchParams, serializeBookingSearchParams,
} from "../../utils/url" } from "../../../utils/url"
import { clearRooms } from "./clearRooms" import { clearRooms } from "../clearRooms"
import { DebugButton } from "./DebugButton" import { DebugButton } from "../DebugButton"
import { findUnavailableSelectedRooms } from "./findUnavailableSelectedRooms" import { findUnavailableSelectedRooms } from "../findUnavailableSelectedRooms"
import { getSelectedPackages } from "./getSelectedPackages" import { getSelectedPackages } from "../getSelectedPackages"
import { getTotalPrice } from "./getTotalPrice" import { getTotalPrice } from "../getTotalPrice"
import { includeRoomInfo } from "./includeRoomInfo" import { includeRoomInfo } from "../includeRoomInfo"
import { isRateSelected as isRateSelected_Inner } from "./isRateSelected" import { isRateSelected as isRateSelected_Inner } from "../isRateSelected"
import { calculateNumberOfNights } from "./calculateNumberOfNights"
import { getLowestRoomPrice } from "./getLowestRoomPrice"
import type { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast" import type { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import type { PriceProduct } from "@scandic-hotels/trpc/types/roomAvailability"
import type { SelectRateBooking } from "../../types/components/selectRate/selectRate" import type { SelectRateBooking } from "../../../types/components/selectRate/selectRate"
import type { Price } from "../../types/price" import type { Price } from "../../../types/price"
import type { import type {
AvailabilityWithRoomInfo, AvailabilityWithRoomInfo,
DefaultRoomPackage, DefaultRoomPackage,
@@ -47,7 +47,7 @@ import type {
RoomPackage, RoomPackage,
SelectedRate, SelectedRate,
SelectRateContext, SelectRateContext,
} from "./types" } from "../types"
const SelectRateContext = createContext<SelectRateContext>( const SelectRateContext = createContext<SelectRateContext>(
{} as SelectRateContext {} as SelectRateContext
@@ -514,37 +514,6 @@ const getDefaultRoomPackages = (intl: IntlShape): DefaultRoomPackage[] =>
inventories: [], inventories: [],
})) }))
function calculateNumberOfNights(
fromDate: string | Date | undefined,
toDate: string | Date | undefined
): number {
if (!fromDate || !toDate) return 0
return dt(toDate).diff(dt(fromDate), "day")
}
function getLowestRoomPrice(
roomAvailability: (AvailabilityWithRoomInfo | null)[][]
) {
// First room is always cheapest room because sort by price is default
const firstRoomAvailability = roomAvailability[0]
return firstRoomAvailability
.filter((room) => !!room)[0]
.products.filter(
(product): product is PriceProduct =>
!!(
("public" in product && product.public) ||
("member" in product && product.member)
)
)
.map((product) => ({
currency: (product.member?.localPrice.currency ||
product.public?.localPrice.currency)!,
price: (product.member?.localPrice.pricePerNight ||
product.public?.localPrice.pricePerNight)!,
}))[0]
}
function getAvailabilityForRoom( function getAvailabilityForRoom(
roomIndex: number, roomIndex: number,
roomAvailability: (AvailabilityWithRoomInfo | null)[][] | undefined roomAvailability: (AvailabilityWithRoomInfo | null)[][] | undefined

View File

@@ -35,7 +35,7 @@ export type SelectRateContext = {
getLowestRoomPrice: () => { getLowestRoomPrice: () => {
price: number price: number
currency: CurrencyEnum currency: CurrencyEnum
} } | null
getPackagesForRoom: (roomIndex: number) => { getPackagesForRoom: (roomIndex: number) => {
selectedPackages: RoomPackage[] selectedPackages: RoomPackage[]