From 94315fc336671d617c1f09acc7766ef701973193 Mon Sep 17 00:00:00 2001 From: Joey Scales Date: Tue, 21 Jul 2026 14:54:25 -0400 Subject: [PATCH] fix: exclude room-less Property from every price bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close #10. `filterHotels` guarded only the max_price side of the band. A room-less Property has `price_from = Infinity`, and `Infinity < minPrice` is `false`, so such a Property wrongly *passed* a `min_price` floor — a null-priced hotel surfacing inside a price-bounded result. Root cause (confirmed by a red test): the asymmetric guard. A Property with no bookable rate has no price and should satisfy neither bound. Guard the non-finite `price_from` up front so it fails both the floor and the ceiling symmetrically. - Regression test: extend the room-less case in search.test.ts to cover the min_price floor and a full band, alongside the existing max_price ceiling. - Document the ruling in ADR-0003 (consequences) and ASSUMPTIONS.md. The seed has 0 room-less Properties (40/40 have Rooms), so this is a defensive-path correctness fix with no observable change today. Co-Authored-By: Claude Opus 4.8 (1M context) --- ASSUMPTIONS.md | 6 ++++++ docs/adr/0003-price-filter-on-cheapest-room.md | 1 + src/domain/search.ts | 4 ++++ test/search.test.ts | 7 ++++++- 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/ASSUMPTIONS.md b/ASSUMPTIONS.md index d387b26..cdf81b1 100644 --- a/ASSUMPTIONS.md +++ b/ASSUMPTIONS.md @@ -37,6 +37,12 @@ not. projection use. A room-less property has no rate; `price_from` renders as `null`. ([ADR-0003](docs/adr/0003-price-filter-on-cheapest-room.md)) +- **A room-less property satisfies no price bound.** Having no bookable rate, it is + excluded from *any* `min_price`/`max_price` result — symmetrically on both sides of the + band, not just the ceiling. The current seed has no room-less properties, so this is a + defensive-path guarantee rather than an observable case today. + ([ADR-0003](docs/adr/0003-price-filter-on-cheapest-room.md)) + - **Price filtering is independent of availability.** `GET /hotels` is a discovery surface — "what exists in my budget" — and returns a property regardless of whether its rooms are open on any particular dates. `/hotels` doesn't even take dates; date-based diff --git a/docs/adr/0003-price-filter-on-cheapest-room.md b/docs/adr/0003-price-filter-on-cheapest-room.md index 6d9c9c2..02f6014 100644 --- a/docs/adr/0003-price-filter-on-cheapest-room.md +++ b/docs/adr/0003-price-filter-on-cheapest-room.md @@ -13,3 +13,4 @@ The brief asks to filter properties by price, but a property has no price of its - A property whose only room has no open dates still appears in price-filtered `/hotels` results, because it still has a listed nightly rate. Availability is resolved later, per-property, on the rooms endpoint. - `price_from` is also returned in the list projection, so a client can display "from $X" without a second call. - `min_price > max_price` is contradictory input → `400`. +- A room-less property has no `price_from` (the primitive yields `Infinity`), so it has no price to satisfy *any* bound and is excluded from every price-filtered result — symmetrically on both the `min_price` floor and the `max_price` ceiling, not just the ceiling. (The seed has no room-less properties; this is a defensive-path guarantee.) diff --git a/src/domain/search.ts b/src/domain/search.ts index d8906cc..4ed57ba 100644 --- a/src/domain/search.ts +++ b/src/domain/search.ts @@ -51,6 +51,10 @@ export function filterHotels(properties: Property[], filters: HotelFilters): Pro if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { const price = priceFrom(property); + // A room-less Property has no bookable rate (`priceFrom` yields Infinity), + // so it has no price to satisfy *any* bound — exclude it from every + // price-filtered result, symmetrically on both sides of the band. + if (!Number.isFinite(price)) return false; if (filters.minPrice !== undefined && price < filters.minPrice) return false; if (filters.maxPrice !== undefined && price > filters.maxPrice) return false; } diff --git a/test/search.test.ts b/test/search.test.ts index cb7326f..d08ec55 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -65,11 +65,16 @@ describe('priceFrom', () => { ); }); - it('yields Infinity for a Property with no Rooms, so it never satisfies a max_price', () => { + it('yields Infinity for a Property with no Rooms, so it satisfies neither price bound', () => { const roomless = makeProperty({ roomPrices: [] }); expect(priceFrom(roomless)).toBe(Infinity); + // A Property with no bookable rate has no price, so it must be excluded by + // any price filter — the max_price ceiling *and* the min_price floor. expect(filterHotels([roomless], { maxPrice: 1000 })).toEqual([]); + expect(filterHotels([roomless], { minPrice: 1000 })).toEqual([]); + expect(filterHotels([roomless], { minPrice: 0 })).toEqual([]); + expect(filterHotels([roomless], { minPrice: 100, maxPrice: 500 })).toEqual([]); }); });