325 lines
12 KiB
Python
325 lines
12 KiB
Python
"""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
|