Skip to content

Commit cca3201

Browse files
authored
Merge pull request #8785 from Couchers-org/na/web/fix-hostreq-clear
Handle edge case dates different days surfer vs host
2 parents 17c1a30 + 1abf7bb commit cca3201

6 files changed

Lines changed: 302 additions & 4 deletions

File tree

app/backend/resources/timezone_areas.sql-fake

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ INSERT INTO "public"."timezone_areas" ("geom" , "tzid") VALUES ('0106000020E6100
33
INSERT INTO "public"."timezone_areas" ("geom" , "tzid") VALUES ('0106000020E6100000010000000103000000010000000600000000074509CEE3F43F6463F24A5013494060DC4A2BE5D50540F2E85C2D90C94940001CF5CCE3B2CFBF074EABEBDA494F406853262981B62CC0265518B6AC374D40E46FDA52C86D24C0D7DD8768105D484000074509CEE3F43F6463F24A50134940', 'Europe/London');
44
INSERT INTO "public"."timezone_areas" ("geom" , "tzid") VALUES ('0106000020E610000001000000010300000001000000060000004B768801E3955CC0B0BA77EF5DB248402E1E2F1EC0A35FC00B531BF8606E484072D0A964C2705FC0A6B8AF6FA1C94240CE84A7D55EF35CC028A4E4C5C08D3A408C0BC59FEDA75BC0A03FEF38BCF33A404B768801E3955CC0B0BA77EF5DB24840', 'America/Los_Angeles');
55
INSERT INTO "public"."timezone_areas" ("geom" , "tzid") VALUES ('0106000020E61000000100000001030000000100000006000000E7EE7FC8AB4563404427DF43430E3CC05E4783784F9661400CC6EA4031BD3CC0FA8A433E499A6140A802CCFE9D7643C058C4AE924043624041F102AB8EB146C09AC37C83F6156340BEA64EB85F9E43C0E7EE7FC8AB4563404427DF43430E3CC0', 'Australia/Sydney');
6+
INSERT INTO "public"."timezone_areas" ("geom" , "tzid") VALUES ('0106000020E610000001000000010300000001000000050000000000000000C063C0000000000000F83F00000000009063C0000000000000F83F00000000009063C000000000000004400000000000C063C000000000000004400000000000C063C0000000000000F83F', 'Pacific/Kiritimati');
67
INSERT INTO "public"."timezone_areas" ("geom" , "tzid") VALUES ('0106000020E6100000010000000103000000010000000500000000000000008066C0000000000080564000000000008066C000000000008056C0000000000080664000000000008056C0DA773767C2856640000000000080564000000000008066C00000000000805640', 'Etc/UTC');

app/backend/src/data/dummy_users.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,35 @@
263263
"regions_lived": ["AUS"],
264264
"hosting_status": "HOSTING_STATUS_CAN_HOST"
265265
},
266+
{
267+
"username": "kiritimati",
268+
"email": "kiritimati@couchers.org",
269+
"password": null,
270+
"name": "Kiri",
271+
"location": {
272+
"city": "Kiritimati Island, Kiribati",
273+
"lat": 1.87,
274+
"lng": -157.47,
275+
"radius": 500
276+
},
277+
"verification": 0.85,
278+
"community_standing": 0.8,
279+
"gender": "Female",
280+
"birthdate": {
281+
"year": 1992,
282+
"month": 6,
283+
"day": 15
284+
},
285+
"languages": [
286+
["eng", "fluent"]
287+
],
288+
"occupation": "Marine Biologist",
289+
"about_me": "Living on the world's most ahead-of-time island — UTC+14 means I'm always first to see the new day!",
290+
"about_place": "A spare room on the most easterly timezone on Earth.",
291+
"regions_visited": ["AUS", "NZL", "USA"],
292+
"regions_lived": ["AUS"],
293+
"hosting_status": "HOSTING_STATUS_CAN_HOST"
294+
},
266295
{
267296
"username": "chagai",
268297
"email": "chagai95@gmail.com",

app/backend/src/tests/test_requests.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import html
22
import re
3-
from datetime import timedelta
3+
from datetime import date, timedelta
4+
from unittest.mock import patch
45
from urllib.parse import parse_qs, urlparse
56

67
import grpc
@@ -209,6 +210,61 @@ def test_create_request(db, moderator):
209210
assert e.value.details() == "You cannot request to stay with someone for longer than one year."
210211

211212

213+
def test_create_host_request_rejects_date_past_in_host_timezone(db):
214+
# When the host's timezone has already rolled over to the next day, a
215+
# from_date of "today in UTC" is in the past from the host's perspective and
216+
# must be rejected. The frontend blocks this date before submission; the
217+
# backend enforces the same rule for consistency.
218+
user1, token1 = generate_user()
219+
# geom inside the fake Europe/Helsinki timezone polygon used in tests
220+
user2, _ = generate_user(geom=create_coordinate(61, 25))
221+
222+
# Helsinki is already on 2026-01-16; requester submits 2026-01-15.
223+
fake_today_by_tz = {"Europe/Helsinki": date(2026, 1, 16)}
224+
225+
with patch(
226+
"couchers.servicers.requests.today_in_timezone",
227+
side_effect=lambda tz: fake_today_by_tz.get(tz, date(2026, 1, 15)),
228+
):
229+
with requests_session(token1) as api:
230+
with pytest.raises(grpc.RpcError) as e:
231+
api.CreateHostRequest(
232+
requests_pb2.CreateHostRequestReq(
233+
host_user_id=user2.id,
234+
from_date="2026-01-15",
235+
to_date="2026-01-18",
236+
text=valid_request_text(),
237+
)
238+
)
239+
assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
240+
241+
242+
def test_create_host_request_date_valid_when_host_behind_requester(db):
243+
# Simulate the opposite timezone direction: the host (America/New_York) is
244+
# still on 2026-01-15 while the requester has already rolled into 2026-01-16.
245+
# A from_date of 2026-01-16 is "today" for the requester and "tomorrow" for
246+
# the host — must be accepted without issue.
247+
user1, token1 = generate_user()
248+
user2, _ = generate_user() # default geom resolves to America/New_York
249+
250+
fake_today_by_tz = {"America/New_York": date(2026, 1, 15)}
251+
252+
with patch(
253+
"couchers.servicers.requests.today_in_timezone",
254+
side_effect=lambda tz: fake_today_by_tz.get(tz, date(2026, 1, 15)),
255+
):
256+
with requests_session(token1) as api:
257+
res = api.CreateHostRequest(
258+
requests_pb2.CreateHostRequestReq(
259+
host_user_id=user2.id,
260+
from_date="2026-01-16",
261+
to_date="2026-01-20",
262+
text=valid_request_text(),
263+
)
264+
)
265+
assert res.host_request_id
266+
267+
212268
def test_create_request_incomplete_profile(db):
213269
user1, token1 = generate_user(complete_profile=False)
214270
user2, _ = generate_user()

app/web/features/profile/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@
224224
"guide_link_help_text": "<0>Read our guide</0> on how to write a request that will get accepted.",
225225
"arrival_date": "Arrival Date",
226226
"arrival_date_empty": "Please specify an arrival date.",
227+
"arrival_date_before_host_today": "This date has already passed for {{name}}.",
227228
"departure_date": "Departure Date",
228229
"departure_date_empty": "Please specify a departure date.",
229230
"request": "Request",
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import "utils/dayjs"; // ensure dayjs timezone plugin is registered
2+
3+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4+
import userEvent from "@testing-library/user-event";
5+
import { service } from "service";
6+
import users from "test/fixtures/users.json";
7+
import wrapper from "test/hookWrapper";
8+
import i18n from "test/i18n";
9+
import { addDefaultUser, mockConsoleError, MockedService } from "test/utils";
10+
11+
import { ProfileUserProvider } from "../hooks/useProfileUser";
12+
import NewHostRequest from "./NewHostRequest";
13+
14+
const { t } = i18n;
15+
16+
jest.mock("@mui/x-date-pickers", () => ({
17+
...jest.requireActual("@mui/x-date-pickers"),
18+
DatePicker: jest.requireActual("@mui/x-date-pickers").DesktopDatePicker,
19+
}));
20+
21+
jest.mock("features/analytics/hooks", () => ({
22+
useLogEvent: () => jest.fn(),
23+
}));
24+
25+
jest.mock("features/analytics/foregroundTracker", () => ({
26+
createForegroundTracker: () => ({
27+
onVisibilityChange: jest.fn(),
28+
finalize: () => ({ foregroundMs: 0, totalMs: 0 }),
29+
}),
30+
}));
31+
32+
jest.mock("features/userQueries/useLiteUsers", () => ({
33+
useLiteUser: () => ({ isLoading: false, error: null }),
34+
}));
35+
36+
const createHostRequestMock = service.requests
37+
.createHostRequest as MockedService<
38+
typeof service.requests.createHostRequest
39+
>;
40+
41+
const [, hostUser] = users; // funnydog, userId=2
42+
43+
const LONG_TEXT = "a".repeat(250);
44+
45+
function renderNewHostRequest() {
46+
render(
47+
<ProfileUserProvider user={hostUser}>
48+
<NewHostRequest
49+
setIsRequestSuccess={jest.fn()}
50+
setIsRequesting={jest.fn()}
51+
/>
52+
</ProfileUserProvider>,
53+
{ wrapper },
54+
);
55+
}
56+
57+
describe("NewHostRequest", () => {
58+
beforeEach(() => {
59+
addDefaultUser();
60+
jest.useFakeTimers();
61+
jest.setSystemTime(new Date("2026-05-24"));
62+
});
63+
64+
afterEach(() => {
65+
jest.useRealTimers();
66+
});
67+
68+
it("preserves form data when the API call fails", async () => {
69+
mockConsoleError();
70+
createHostRequestMock.mockRejectedValue(new Error("Network error"));
71+
renderNewHostRequest();
72+
73+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
74+
75+
const arrivalGroup = await screen.findByRole("group", {
76+
name: t("profile:request_form.arrival_date"),
77+
});
78+
await user.click(arrivalGroup);
79+
await user.keyboard("{Control>}a{/Control}");
80+
await user.keyboard("06012026");
81+
82+
const departureGroup = screen.getByRole("group", {
83+
name: t("profile:request_form.departure_date"),
84+
});
85+
await user.click(departureGroup);
86+
await user.keyboard("{Control>}a{/Control}");
87+
await user.keyboard("06052026");
88+
89+
const textArea = screen.getByLabelText(t("profile:request_form.request"));
90+
fireEvent.change(textArea, { target: { value: LONG_TEXT } });
91+
92+
await user.click(screen.getByRole("button", { name: t("global:send") }));
93+
94+
expect(await screen.findByRole("alert")).toHaveTextContent("Network error");
95+
expect(textArea).toHaveValue(LONG_TEXT);
96+
});
97+
98+
it("rejects an arrival date that is already in the past in the host's timezone", async () => {
99+
// At 2026-05-24T22:00:00Z it is still May 24 for the requester (UTC), but
100+
// the host (Europe/Helsinki, UTC+3) is already on May 25. The requester
101+
// choosing "today" (May 24) should be treated as a past date and the form
102+
// should NOT submit to the API.
103+
jest.setSystemTime(new Date("2026-05-24T22:00:00Z"));
104+
105+
createHostRequestMock.mockResolvedValue(1);
106+
render(
107+
<ProfileUserProvider user={hostUser}>
108+
<NewHostRequest
109+
setIsRequestSuccess={jest.fn()}
110+
setIsRequesting={jest.fn()}
111+
/>
112+
</ProfileUserProvider>,
113+
{ wrapper },
114+
);
115+
116+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
117+
118+
// Type May 24 — today from the requester's perspective, but yesterday in
119+
// Helsinki where the host lives.
120+
const arrivalGroup = await screen.findByRole("group", {
121+
name: t("profile:request_form.arrival_date"),
122+
});
123+
await user.click(arrivalGroup);
124+
await user.keyboard("{Control>}a{/Control}");
125+
await user.keyboard("05242026");
126+
127+
const departureGroup = screen.getByRole("group", {
128+
name: t("profile:request_form.departure_date"),
129+
});
130+
await user.click(departureGroup);
131+
await user.keyboard("{Control>}a{/Control}");
132+
await user.keyboard("05282026");
133+
134+
const textArea = screen.getByLabelText(t("profile:request_form.request"));
135+
fireEvent.change(textArea, { target: { value: LONG_TEXT } });
136+
137+
await user.click(screen.getByRole("button", { name: t("global:send") }));
138+
139+
// The form should catch the invalid date before hitting the API.
140+
expect(createHostRequestMock).not.toHaveBeenCalled();
141+
// The request text must still be intact so the user doesn't lose their work.
142+
expect(textArea).toHaveValue(LONG_TEXT);
143+
});
144+
145+
it("allows an arrival date that is today in the host's timezone when the requester is ahead", async () => {
146+
// At 2026-05-25T05:00:00Z it is May 25 in UTC but still May 24 in
147+
// America/Los_Angeles (UTC-7). Picking May 25 is the host's "tomorrow" —
148+
// a valid future date that must go through.
149+
jest.setSystemTime(new Date("2026-05-25T05:00:00Z"));
150+
151+
createHostRequestMock.mockResolvedValue(1);
152+
const hostBehindTimezone = { ...users[1], timezone: "America/Los_Angeles" };
153+
render(
154+
<ProfileUserProvider user={hostBehindTimezone}>
155+
<NewHostRequest
156+
setIsRequestSuccess={jest.fn()}
157+
setIsRequesting={jest.fn()}
158+
/>
159+
</ProfileUserProvider>,
160+
{ wrapper },
161+
);
162+
163+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
164+
165+
const arrivalGroup = await screen.findByRole("group", {
166+
name: t("profile:request_form.arrival_date"),
167+
});
168+
await user.click(arrivalGroup);
169+
await user.keyboard("{Control>}a{/Control}");
170+
await user.keyboard("05252026");
171+
172+
const departureGroup = screen.getByRole("group", {
173+
name: t("profile:request_form.departure_date"),
174+
});
175+
await user.click(departureGroup);
176+
await user.keyboard("{Control>}a{/Control}");
177+
await user.keyboard("05302026");
178+
179+
fireEvent.change(screen.getByLabelText(t("profile:request_form.request")), {
180+
target: { value: LONG_TEXT },
181+
});
182+
183+
await user.click(screen.getByRole("button", { name: t("global:send") }));
184+
185+
await waitFor(() => expect(createHostRequestMock).toHaveBeenCalled());
186+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
187+
});
188+
});

app/web/features/profile/view/NewHostRequest.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import Button from "components/Button";
55
import Datepicker from "components/Datepicker";
66
import StyledLink from "components/StyledLink";
77
import TextField from "components/TextField";
8-
import dayjs from "dayjs";
98
import { createForegroundTracker } from "features/analytics/foregroundTracker";
109
import { useLogEvent } from "features/analytics/hooks";
1110
import {
@@ -23,6 +22,7 @@ import { service } from "service";
2322
import { CreateHostRequestWrapper } from "service/requests";
2423
import { theme } from "theme";
2524
import { isSameOrFutureDate } from "utils/date";
25+
import dayjs from "utils/dayjs";
2626

2727
const TYPING_GAP_CAP_MS = 3000;
2828

@@ -224,6 +224,7 @@ export default function NewHostRequest({
224224

225225
onSuccess: () => {
226226
submittedRef.current = true;
227+
reset();
227228
setIsRequesting(false);
228229
setIsRequestSuccess(true);
229230
},
@@ -233,10 +234,16 @@ export default function NewHostRequest({
233234

234235
const onSubmit = handleSubmit((data) => {
235236
mutate(data);
236-
reset();
237237
});
238238

239+
const hostToday = user.timezone
240+
? dayjs().tz(user.timezone).startOf("day")
241+
: dayjs().startOf("day");
242+
239243
const watchFromDate = watch("fromDate", undefined);
244+
const arrivalBeforeHostToday =
245+
!!watchFromDate && dayjs(watchFromDate).isBefore(hostToday);
246+
240247
useEffect(() => {
241248
if (
242249
watchFromDate &&
@@ -271,11 +278,27 @@ export default function NewHostRequest({
271278
label={t("profile:request_form.arrival_date")}
272279
name="fromDate"
273280
defaultValue={null}
281+
minDate={hostToday}
274282
rules={{
275283
required: t("profile:request_form.arrival_date_empty"),
276-
validate: (stringDate) => stringDate !== "",
284+
validate: {
285+
notEmpty: (date) => !date || date !== "",
286+
notBeforeHostToday: (date) =>
287+
!date ||
288+
!dayjs(date).isBefore(hostToday) ||
289+
t("profile:request_form.arrival_date_before_host_today", {
290+
name: user.name,
291+
}),
292+
},
277293
}}
278294
/>
295+
{arrivalBeforeHostToday && (
296+
<Alert severity="error">
297+
{t("profile:request_form.arrival_date_before_host_today", {
298+
name: user.name,
299+
})}
300+
</Alert>
301+
)}
279302
<StyledDatepicker
280303
control={control}
281304
error={!!errors.toDate}

0 commit comments

Comments
 (0)