from datetime import UTC, date, datetime, time, timedelta import pytest def next_weekday(target: int) -> date: today = date.today() delta = (target - today.weekday()) % 7 if delta == 0: delta = 7 return today + timedelta(days=delta) async def create_verified_center(client, owner_headers, admin_headers, internal_headers, *, city: str = "Seoul") -> dict: center = ( await client.post( "/api/service-centers", headers=owner_headers, json={ "display_name": f"Booking Service {city}", "country": "KR", "city": city, "specializations": ["oil_change", "diagnostics"], }, ) ).json() await client.post( "/api/users", headers=internal_headers, json={"telegram_id": 9001, "platform_role": "admin"}, ) response = await client.post(f"/api/admin/service-centers/{center['id']}/verify", headers=admin_headers) assert response.status_code == 200 return response.json() @pytest.mark.asyncio async def test_sto_catalog_shows_only_approved_and_filters_city( client, auth_headers, admin_auth_headers, internal_headers ) -> None: approved = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers, city="Seoul") await client.post( "/api/service-centers", headers=auth_headers, json={"display_name": "Pending Booking Service", "country": "KR", "city": "Busan"}, ) response = await client.get("/api/sto/catalog?city=Seoul", headers=auth_headers) assert response.status_code == 200 ids = [item["id"] for item in response.json()] assert ids == [approved["id"]] @pytest.mark.asyncio async def test_available_slots_skip_weekend_lunch_and_holidays( client, auth_headers, admin_auth_headers, internal_headers ) -> None: center = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers) monday = next_weekday(0) saturday = next_weekday(5) await client.post( "/api/sto/settings/booking", headers=auth_headers, json={ "service_center_id": center["id"], "working_days": [0], "open_time": "09:00:00", "close_time": "13:00:00", "lunch_break_start": "11:00:00", "lunch_break_end": "12:00:00", "slot_duration_minutes": 60, "max_parallel_bookings": 1, "accepts_online_booking": True, }, ) await client.post( "/api/sto/settings/holidays", headers=auth_headers, json={"service_center_id": center["id"], "holiday_date": saturday.isoformat(), "reason": "Closed"}, ) monday_slots = ( await client.get( f"/api/sto/{center['id']}/available-slots?date_from={monday}&date_to={monday}&duration_minutes=60", headers=auth_headers, ) ).json() saturday_slots = ( await client.get( f"/api/sto/{center['id']}/available-slots?date_from={saturday}&date_to={saturday}&duration_minutes=60", headers=auth_headers, ) ).json() assert {datetime.fromisoformat(slot["start_at"]).time() for slot in monday_slots} == {time(9, 0), time(10, 0), time(12, 0)} assert saturday_slots == [] @pytest.mark.asyncio async def test_customer_booking_lifecycle_capacity_calendar_work_order_and_notifications( client, auth_headers, other_auth_headers, admin_auth_headers, internal_headers ) -> None: center = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers) workday = next_weekday(0) start_at = datetime.combine(workday, time(10, 0), tzinfo=UTC) proposed_at = datetime.combine(workday, time(14, 0), tzinfo=UTC) await client.post( "/api/sto/settings/booking", headers=auth_headers, json={ "service_center_id": center["id"], "working_days": [0, 1, 2, 3, 4], "open_time": "09:00:00", "close_time": "18:00:00", "slot_duration_minutes": 60, "max_parallel_bookings": 1, "accepts_online_booking": True, }, ) vehicle = ( await client.post( "/api/my/vehicles", headers=other_auth_headers, json={"name": "Client car", "current_odometer": 45000, "oil_change_interval_km": 10000}, ) ).json() past_response = await client.post( "/api/appointments", headers=other_auth_headers, json={ "service_center_id": center["id"], "vehicle_id": vehicle["id"], "service_type": "oil_change", "service_name": "Oil change", "requested_start_at": (datetime.now(UTC) - timedelta(days=1)).isoformat(), "estimated_duration_minutes": 60, }, ) assert past_response.status_code == 409 created = await client.post( "/api/appointments", headers=other_auth_headers, json={ "service_center_id": center["id"], "vehicle_id": vehicle["id"], "service_type": "oil_change", "service_name": "Oil change", "requested_start_at": start_at.isoformat(), "estimated_duration_minutes": 60, "customer_comment": "Need synthetic oil", }, ) assert created.status_code == 201 appointment = created.json() assert appointment["status"] == "requested" duplicate = await client.post( "/api/appointments", headers=other_auth_headers, json={ "service_center_id": center["id"], "vehicle_id": vehicle["id"], "service_type": "diagnostics", "service_name": "Diagnostics", "requested_start_at": start_at.isoformat(), "estimated_duration_minutes": 60, }, ) assert duplicate.status_code == 409 proposed = await client.post( f"/api/sto/appointments/{appointment['id']}/propose-time", headers=auth_headers, json={"proposed_start_at": proposed_at.isoformat(), "comment": "Better window"}, ) assert proposed.status_code == 200 assert proposed.json()["status"] == "proposed_new_time" accepted = await client.post( f"/api/appointments/{appointment['id']}/accept-proposed-time", headers=other_auth_headers, ) assert accepted.status_code == 200 assert accepted.json()["status"] == "confirmed" calendar = await client.get( f"/api/sto/calendar?service_center_id={center['id']}&date_from={workday.isoformat()}T00:00:00Z&date_to={workday.isoformat()}T23:59:59Z", headers=auth_headers, ) assert calendar.status_code == 200 assert calendar.json()[0]["id"] == appointment["id"] work_order = await client.post( f"/api/sto/appointments/{appointment['id']}/create-work-order", headers=auth_headers, json={"odometer": 45100}, ) assert work_order.status_code == 201 assert work_order.json()["vehicle_id"] == vehicle["id"] my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers) assert my_appointments.json()[0]["linked_work_order_id"] == work_order.json()["id"] owner_delete_converted = await client.delete(f"/api/appointments/{appointment['id']}", headers=other_auth_headers) sto_delete_converted = await client.delete(f"/api/sto/appointments/{appointment['id']}", headers=auth_headers) assert owner_delete_converted.status_code == 409 assert sto_delete_converted.status_code == 409 @pytest.mark.asyncio async def test_appointments_can_be_deleted_by_owner_or_sto_before_work_order( client, auth_headers, other_auth_headers, admin_auth_headers, internal_headers ) -> None: center = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers, city="Delete Booking") workday = next_weekday(2) await client.post( "/api/sto/settings/booking", headers=auth_headers, json={ "service_center_id": center["id"], "working_days": [0, 1, 2, 3, 4], "open_time": "09:00:00", "close_time": "18:00:00", "slot_duration_minutes": 60, "max_parallel_bookings": 1, "accepts_online_booking": True, }, ) vehicle = ( await client.post( "/api/my/vehicles", headers=other_auth_headers, json={"name": "Delete booking car", "current_odometer": 12000}, ) ).json() owner_deleted = ( await client.post( "/api/appointments", headers=other_auth_headers, json={ "service_center_id": center["id"], "vehicle_id": vehicle["id"], "service_type": "diagnostics", "service_name": "Diagnostics", "requested_start_at": datetime.combine(workday, time(10, 0), tzinfo=UTC).isoformat(), "estimated_duration_minutes": 60, }, ) ).json() deleted_by_owner = await client.delete(f"/api/appointments/{owner_deleted['id']}", headers=other_auth_headers) assert deleted_by_owner.status_code == 204 sto_deleted = ( await client.post( "/api/appointments", headers=other_auth_headers, json={ "service_center_id": center["id"], "vehicle_id": vehicle["id"], "service_type": "diagnostics", "service_name": "Diagnostics", "requested_start_at": datetime.combine(workday, time(11, 0), tzinfo=UTC).isoformat(), "estimated_duration_minutes": 60, }, ) ).json() deleted_by_sto = await client.delete(f"/api/sto/appointments/{sto_deleted['id']}", headers=auth_headers) assert deleted_by_sto.status_code == 204 my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers) assert {item["id"] for item in my_appointments.json()}.isdisjoint({owner_deleted["id"], sto_deleted["id"]}) @pytest.mark.asyncio async def test_recommendations_can_be_generated_dismissed_and_linked_to_booking( client, auth_headers, other_auth_headers, admin_auth_headers, internal_headers ) -> None: center = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers) workday = next_weekday(1) start_at = datetime.combine(workday, time(10, 0), tzinfo=UTC) vehicle = ( await client.post( "/api/my/vehicles", headers=other_auth_headers, json={"name": "Recommendation car", "current_odometer": 90000, "oil_change_interval_km": 10000}, ) ).json() recommendations = await client.get( f"/api/vehicles/{vehicle['id']}/maintenance-recommendations", headers=other_auth_headers, ) assert recommendations.status_code == 200 oil_recommendation = recommendations.json()[0] assert oil_recommendation["recommendation_type"] == "oil_change" appointment = ( await client.post( "/api/appointments", headers=other_auth_headers, json={ "service_center_id": center["id"], "vehicle_id": vehicle["id"], "service_type": "oil_change", "service_name": "Oil change", "requested_start_at": start_at.isoformat(), "estimated_duration_minutes": 60, "source_recommendation_id": oil_recommendation["id"], }, ) ).json() booked = await client.post( f"/api/maintenance-recommendations/{oil_recommendation['id']}/book", headers=other_auth_headers, json={"appointment_id": appointment["id"]}, ) assert booked.status_code == 200 assert booked.json()["status"] == "booked" custom = ( await client.post( f"/api/vehicles/{vehicle['id']}/maintenance-recommendations", headers=other_auth_headers, json={"recommendation_type": "brakes", "title": "Check brakes", "priority": "low"}, ) ).json() dismissed = await client.post( f"/api/maintenance-recommendations/{custom['id']}/dismiss", headers=other_auth_headers, ) assert dismissed.status_code == 200 assert dismissed.json()["status"] == "dismissed"