"""The date/time boundary, and what a bad slot does now. MNT-12. The same parsing block lived in four modules with three different answers to the same bad input: flash and redirect, set the value to None and report success, or `continue` in silence. Waves E and G closed the match side with schemas; this covers what was left — availability, coach bookings and one-on-one requests. Two of the findings here were not in the constat. `OneOnOneRequestSchema` and `DisponibilityAddSchema` were defined in validators.py and **called from nowhere**: no route imported them, no test exercised them. That is the SEC-AUTHZ-001 pattern — a validation policy written down and not applied — surviving in a corner nobody had revisited. And `manage_coach_availability` deleted every existing slot before re-adding the ones it could parse, skipping the rest silently and answering `{'success': true}`. A payload the browser mangled therefore wiped a coach's bookable hours and reported success. One-on-one requests are refused against exactly that table, so the coach became unbookable with nothing to show. """ from datetime import date, time import pytest from app.extensions import db from app.timeslots import DEFAULT_SLOT_MINUTES, day_name, slot_end class TestTheSlotRule: def test_a_slot_lasts_the_default(self): assert slot_end(time(9, 0)) == time(9, 30) def test_the_default_is_the_one_the_ui_draws(self): assert DEFAULT_SLOT_MINUTES == 30 def test_it_does_not_depend_on_the_day_it_is_computed(self): """The old helpers anchored on `datetime.today()`, so the result of a pure arithmetic function depended on when it ran.""" assert slot_end(time(23, 45)) == slot_end(time(23, 45), on=date(2031, 2, 28)) def test_a_slot_may_wrap_past_midnight(self): """Preserved from the previous implementations rather than changed: a coach may plausibly be free at 23:45, and refusing it here would be a new rule smuggled in with a refactor.""" assert slot_end(time(23, 45)) == time(0, 15) def test_an_out_of_range_day_is_named_not_raised(self): """Indexing the list directly made an unexpected day an IndexError inside a JSON route — a 500 with an HTML body.""" assert day_name(0) == 'Monday' assert day_name(6) == 'Sunday' assert 'Day' in day_name(9) class TestPlayerAvailability: def test_a_valid_slot_is_stored_with_its_computed_end(self, app, client, as_role): from app.models import PlayerDisponibility player_id = as_role('player') response = client.post( '/users/disponibilities/add', data={'day_of_week': '2', 'start_time': '14:00'} ) assert response.status_code == 200 with app.app_context(): slot = PlayerDisponibility.query.filter_by(player_id=player_id).one() assert slot.start_time == time(14, 0) assert slot.end_time == time(14, 30) @pytest.mark.parametrize( 'payload', [ {'day_of_week': '9', 'start_time': '14:00'}, {'day_of_week': 'monday', 'start_time': '14:00'}, {'day_of_week': '2', 'start_time': 'lunchtime'}, {'day_of_week': '2'}, ], ) def test_a_bad_slot_is_refused_in_json(self, app, client, as_role, payload): from app.models import PlayerDisponibility as_role('player') response = client.post('/users/disponibilities/add', data=payload) assert response.status_code == 400 assert response.is_json, 'this is a fetch() endpoint (STD-09)' with app.app_context(): assert PlayerDisponibility.query.count() == 0 class TestBulkAvailabilityReplacementIsAtomic: def test_valid_slots_are_saved(self, app, client, as_role): from app.models import PlayerDisponibility as_role('player') response = client.post( '/users/disponibilities/add_bulk', json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]}, ) assert response.get_json()['rejected'] == [] with app.app_context(): assert PlayerDisponibility.query.count() == 1 def test_a_good_payload_replaces_the_slots(self, app, client, as_role): from app.models import PlayerDisponibility player_id = as_role('player') client.post( '/users/disponibilities/add_bulk', json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]}, ) response = client.post( '/users/disponibilities/add_bulk', json={'slots': [{'day_of_week': 3, 'start_time': '18:00'}]}, ) assert response.status_code == 200 with app.app_context(): slots = PlayerDisponibility.query.filter_by(player_id=player_id).all() assert [(s.day_of_week, s.start_time) for s in slots] == [(3, time(18, 0))] def test_an_empty_payload_clears_the_slots(self, app, client, as_role): from app.models import PlayerDisponibility player_id = as_role('player') client.post( '/users/disponibilities/add_bulk', json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]}, ) response = client.post('/users/disponibilities/add_bulk', json={'slots': []}) assert response.status_code == 200 with app.app_context(): assert PlayerDisponibility.query.filter_by(player_id=player_id).count() == 0 def test_a_malformed_slot_changes_nothing(self, app, client, as_role): from app.models import PlayerDisponibility player_id = as_role('player') client.post( '/users/disponibilities/add_bulk', json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]}, ) response = client.post( '/users/disponibilities/add_bulk', json={ 'slots': [ {'day_of_week': 3, 'start_time': '18:00'}, {'day_of_week': 1, 'start_time': 'nope'}, ] }, ) assert response.status_code == 400 assert len(response.get_json()['rejected']) == 1 with app.app_context(): slots = PlayerDisponibility.query.filter_by(player_id=player_id).all() assert [(s.day_of_week, s.start_time) for s in slots] == [(1, time(9, 0))] class TestCoachAvailabilityIsNotWipedByABadPayload: """The destructive case. This route replaces the whole table for a coach.""" @pytest.fixture def coach_with_slots(self, app, client, as_role): from app.models import CoachAvailability coach_id = as_role('coach') response = client.post( '/users/coach-availability', json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]}, ) assert response.status_code == 200 with app.app_context(): assert CoachAvailability.query.filter_by(coach_id=coach_id).count() == 1 return coach_id def test_a_good_payload_replaces_the_slots(self, app, client, coach_with_slots): from app.models import CoachAvailability client.post( '/users/coach-availability', json={'slots': [{'day_of_week': 3, 'start_time': '18:00'}]}, ) with app.app_context(): slots = CoachAvailability.query.filter_by(coach_id=coach_with_slots).all() assert [(s.day_of_week, s.start_time) for s in slots] == [(3, time(18, 0))] def test_a_malformed_slot_changes_nothing(self, app, client, coach_with_slots): """Before: the delete had already run, the bad slot was skipped, and the answer was `{'success': true}` over an emptied table.""" from app.models import CoachAvailability response = client.post( '/users/coach-availability', json={'slots': [{'day_of_week': 3, 'start_time': 'quarter past'}]}, ) assert response.status_code == 400 with app.app_context(): slots = CoachAvailability.query.filter_by(coach_id=coach_with_slots).all() assert [(s.day_of_week, s.start_time) for s in slots] == [(1, time(9, 0))], ( 'the coach lost their bookable hours to a payload that was refused' ) def test_one_bad_slot_among_good_ones_still_changes_nothing( self, app, client, coach_with_slots ): from app.models import CoachAvailability response = client.post( '/users/coach-availability', json={ 'slots': [ {'day_of_week': 3, 'start_time': '18:00'}, {'day_of_week': 99, 'start_time': '19:00'}, ] }, ) assert response.status_code == 400 with app.app_context(): slots = CoachAvailability.query.filter_by(coach_id=coach_with_slots).all() assert [(s.day_of_week, s.start_time) for s in slots] == [(1, time(9, 0))] class TestOneOnOneRequestGoesThroughItsSchema: @pytest.fixture def bookable(self, app, make_user): """A player on a team whose coach is free Monday 09:00–10:00.""" from app.models import CoachAvailability, OrgTeam, TeamPlayer admin_id = make_user('admin') coach_id = make_user('coach') player_id = make_user('player') with app.app_context(): team = OrgTeam(name='Varsity', created_by=admin_id, coach_id=coach_id) db.session.add(team) db.session.flush() db.session.add(TeamPlayer(player_id=player_id, org_team_id=team.id)) db.session.add( CoachAvailability( coach_id=coach_id, day_of_week=0, start_time=time(9, 0), end_time=time(10, 0), ) ) db.session.commit() return player_id def _login(self, app, client, login, user_id): from app.models import User with app.app_context(): username = db.session.get(User, user_id).username assert login(username).status_code in (301, 302) def test_a_request_inside_the_window_is_recorded(self, app, client, login, bookable): from app.models import OneOnOneRequest self._login(app, client, login, bookable) client.post( '/users/one-on-one', data={ 'date': '2030-01-07', # a Monday 'start_time': '09:00', 'end_time': '09:30', 'points': 'aim', }, ) with app.app_context(): booked = OneOnOneRequest.query.one() assert booked.date == date(2030, 1, 7) assert booked.start_time == time(9, 0) @pytest.mark.parametrize( 'form', [ {'date': 'tomorrow', 'start_time': '09:00', 'end_time': '09:30'}, {'date': '2030-01-07', 'start_time': '9am', 'end_time': '09:30'}, {'date': '2030-01-07', 'start_time': '09:00'}, # End before start: nothing checked this, and the request still # reached the coach's Discord. {'date': '2030-01-07', 'start_time': '09:30', 'end_time': '09:00'}, ], ) def test_a_malformed_request_is_refused(self, app, client, login, bookable, form): from app.models import OneOnOneRequest self._login(app, client, login, bookable) response = client.post('/users/one-on-one', data=form, follow_redirects=False) assert response.status_code < 500 with app.app_context(): assert OneOnOneRequest.query.count() == 0 def test_a_request_outside_the_window_is_still_refused(self, app, client, login, bookable): """The availability check survived the rewrite: it now compares times rather than the original strings.""" from app.models import OneOnOneRequest self._login(app, client, login, bookable) client.post( '/users/one-on-one', data={'date': '2030-01-07', 'start_time': '14:00', 'end_time': '14:30'}, ) with app.app_context(): assert OneOnOneRequest.query.count() == 0