371 lines
13 KiB
Python
371 lines
13 KiB
Python
from contextlib import ExitStack
|
|
from unittest import mock
|
|
|
|
from django.core.cache import cache
|
|
from django.test import TestCase
|
|
|
|
from rq.job import JobStatus
|
|
|
|
from accounts.models import CustomUser
|
|
from events.models import Event
|
|
from games.models import EventGame, Game
|
|
|
|
from . import tasks
|
|
from .models import Scope
|
|
from .services.aandt_client.models import (
|
|
AandtApiInfosResponse200,
|
|
AandtApiMatchesResponse200,
|
|
AandtApiMembersResponse200,
|
|
)
|
|
from .services.aandt_sync import ScopeSyncError, ScopeSyncResult, sync_event_game_scopes
|
|
|
|
|
|
class SyncScopesApiTests(TestCase):
|
|
def setUp(self):
|
|
self.event = Event.objects.create(name="Event", path="event")
|
|
self.game = Game.objects.create(name="Game", path="game")
|
|
self.event_game = EventGame.objects.create(event=self.event, game=self.game)
|
|
self.url = (
|
|
f"/api/events/{self.event.path}/submissions/{self.game.path}/sync-scopes"
|
|
)
|
|
self.superuser = CustomUser.objects.create_user(
|
|
username="admin", password="x", is_staff=True, is_superuser=True
|
|
)
|
|
|
|
patcher = mock.patch("submissions.api.schedule_scope_sync", return_value=True)
|
|
self.schedule = patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def _post(self, body=None):
|
|
return self.client.post(self.url, body or {}, content_type="application/json")
|
|
|
|
def test_staff_is_forbidden(self):
|
|
staff = CustomUser.objects.create_user(
|
|
username="staff", password="x", is_staff=True
|
|
)
|
|
self.client.force_login(staff)
|
|
|
|
self.assertEqual(self._post().status_code, 403)
|
|
self.schedule.assert_not_called()
|
|
|
|
def test_asks_for_token_when_missing(self):
|
|
self.client.force_login(self.superuser)
|
|
|
|
response = self._post()
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertTrue(response.json()["token_required"])
|
|
self.schedule.assert_not_called()
|
|
|
|
def test_stores_token_and_starts_sync(self):
|
|
self.client.force_login(self.superuser)
|
|
|
|
response = self._post({"apibot_token": " abc "})
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.json(), {"started": True})
|
|
self.event_game.refresh_from_db()
|
|
self.assertEqual(self.event_game.apibot_token, "abc")
|
|
self.schedule.assert_called_once_with(self.event_game.id)
|
|
|
|
def test_status(self):
|
|
Scope.objects.create(event_game=self.event_game, name="M1")
|
|
Scope.objects.create(event_game=self.event_game, name="M2", is_active=False)
|
|
self.client.force_login(self.superuser)
|
|
|
|
with mock.patch(
|
|
"submissions.api.get_scope_sync_state",
|
|
return_value=tasks.ScopeSyncState(running=True, aandt_status="7inprog"),
|
|
):
|
|
response = self.client.get(self.url)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.json()
|
|
self.assertTrue(data["running"])
|
|
self.assertFalse(data["has_token"])
|
|
self.assertEqual(data["aandt_status"], "7inprog")
|
|
self.assertEqual((data["active_scope_count"], data["scope_count"]), (1, 2))
|
|
|
|
def test_stop(self):
|
|
self.client.force_login(self.superuser)
|
|
|
|
with mock.patch("submissions.api.stop_scope_sync", return_value=True) as stop:
|
|
response = self.client.delete(self.url)
|
|
|
|
self.assertEqual(response.json(), {"stopped": True})
|
|
stop.assert_called_once_with(self.event_game.id)
|
|
|
|
def test_status_and_stop_forbidden_for_staff(self):
|
|
staff = CustomUser.objects.create_user(
|
|
username="staff", password="x", is_staff=True
|
|
)
|
|
self.client.force_login(staff)
|
|
|
|
self.assertEqual(self.client.get(self.url).status_code, 403)
|
|
self.assertEqual(self.client.delete(self.url).status_code, 403)
|
|
|
|
def test_custom_path_game_is_404(self):
|
|
self.game.custom_path = True
|
|
self.game.save()
|
|
self.client.force_login(self.superuser)
|
|
|
|
self.assertEqual(self._post({"apibot_token": "abc"}).status_code, 404)
|
|
|
|
|
|
class SyncEventGameScopesTaskTests(TestCase):
|
|
def setUp(self):
|
|
event = Event.objects.create(name="Event", path="event")
|
|
game = Game.objects.create(name="Game", path="game")
|
|
self.event_game = EventGame.objects.create(
|
|
event=event, game=game, apibot_token="abc"
|
|
)
|
|
self.guard_key = tasks._scope_sync_guard_key(self.event_game.id)
|
|
self.loop_id = "loop"
|
|
self.job_key = tasks._scope_sync_job_key(self.event_game.id)
|
|
self.state_key = tasks._scope_sync_state_key(self.event_game.id)
|
|
cache.set(self.guard_key, self.loop_id)
|
|
self.addCleanup(
|
|
cache.delete_many, [self.guard_key, self.job_key, self.state_key]
|
|
)
|
|
|
|
@staticmethod
|
|
def _task_mock(task):
|
|
# enqueue() returns a TaskResult, whose id ends up in the cache
|
|
task.enqueue.return_value.id = "job-1"
|
|
task.using.return_value.enqueue.return_value.id = "job-2"
|
|
return task
|
|
|
|
def _run(self, status=None, exc=None, during_sync=None):
|
|
run = tasks.sync_event_game_scopes_task.func
|
|
|
|
def sync_side_effect(_event_game):
|
|
if during_sync:
|
|
during_sync()
|
|
if exc:
|
|
raise exc
|
|
return ScopeSyncResult(status=status)
|
|
|
|
with (
|
|
mock.patch.object(
|
|
tasks, "sync_event_game_scopes", side_effect=sync_side_effect
|
|
) as sync,
|
|
mock.patch.object(tasks, "sync_event_game_scopes_task") as task,
|
|
mock.patch.object(tasks, "logger"),
|
|
):
|
|
self._task_mock(task)
|
|
run(self.event_game.id, loop_id=self.loop_id)
|
|
return sync, task
|
|
|
|
def test_schedule_starts_only_one_loop(self):
|
|
cache.delete(self.guard_key)
|
|
|
|
with mock.patch.object(tasks, "sync_event_game_scopes_task") as task:
|
|
self._task_mock(task)
|
|
self.assertTrue(tasks.schedule_scope_sync(self.event_game.id))
|
|
self.assertFalse(tasks.schedule_scope_sync(self.event_game.id))
|
|
|
|
task.enqueue.assert_called_once()
|
|
|
|
def test_reschedules_before_and_during_aandt(self):
|
|
for status in ["3insc", "7inprog"]:
|
|
with self.subTest(status=status):
|
|
_, task = self._run(status=status)
|
|
task.using.assert_called_once()
|
|
|
|
def test_reschedules_after_api_failure(self):
|
|
_, task = self._run(exc=ScopeSyncError("down"))
|
|
|
|
task.using.assert_called_once()
|
|
|
|
def test_reschedules_after_unexpected_error(self):
|
|
_, task = self._run(exc=RuntimeError("boom"))
|
|
|
|
task.using.assert_called_once()
|
|
state = tasks.get_scope_sync_state(self.event_game.id)
|
|
self.assertEqual(state.error, "boom")
|
|
self.assertTrue(state.running)
|
|
|
|
def test_records_job_and_state(self):
|
|
self._run(status="7inprog")
|
|
|
|
self.assertEqual(cache.get(self.job_key), "job-2")
|
|
state = tasks.get_scope_sync_state(self.event_game.id)
|
|
self.assertEqual(state.aandt_status, "7inprog")
|
|
self.assertIsNotNone(state.next_run_at)
|
|
|
|
def test_stop_cancels_scheduled_job(self):
|
|
cache.set(self.job_key, "job-1")
|
|
job = mock.Mock(get_status=mock.Mock(return_value=JobStatus.SCHEDULED))
|
|
|
|
with (
|
|
mock.patch.object(tasks.Job, "fetch", return_value=job) as fetch,
|
|
mock.patch.object(tasks.django_rq, "get_connection"),
|
|
):
|
|
self.assertTrue(tasks.stop_scope_sync(self.event_game.id))
|
|
|
|
fetch.assert_called_once_with("job-1", connection=mock.ANY)
|
|
job.cancel.assert_called_once()
|
|
self.assertIsNone(cache.get(self.guard_key))
|
|
self.assertFalse(tasks.stop_scope_sync(self.event_game.id))
|
|
|
|
def test_stop_during_run_does_not_reschedule(self):
|
|
_, task = self._run(
|
|
status="7inprog", during_sync=lambda: cache.delete(self.guard_key)
|
|
)
|
|
|
|
task.using.assert_not_called()
|
|
self.assertIsNone(cache.get(self.guard_key))
|
|
|
|
def test_stops_once_aandt_is_done(self):
|
|
_, task = self._run(status="9done")
|
|
|
|
task.using.assert_not_called()
|
|
self.assertIsNone(cache.get(self.guard_key))
|
|
|
|
def test_stale_loop_stops(self):
|
|
cache.set(self.guard_key, "another-loop")
|
|
|
|
sync, task = self._run(status="7inprog")
|
|
|
|
sync.assert_not_called()
|
|
task.using.assert_not_called()
|
|
|
|
|
|
class SyncScopeBroadcastTests(TestCase):
|
|
def setUp(self):
|
|
event = Event.objects.create(name="Event", path="event")
|
|
game = Game.objects.create(name="Game", path="game")
|
|
self.event_game = EventGame.objects.create(
|
|
event=event, game=game, apibot_token="abc"
|
|
)
|
|
self.player = CustomUser.objects.create_user(
|
|
username="player", password="x", cas_user_id="42"
|
|
)
|
|
|
|
def _sync(self, matches):
|
|
api = "submissions.services.aandt_sync"
|
|
responses = {
|
|
"aandt_api_infos": AandtApiInfosResponse200.from_dict(
|
|
{"aandt": {"status": "7inprog"}}
|
|
),
|
|
"aandt_api_members": AandtApiMembersResponse200.from_dict(
|
|
{"members": [{"id": 1, "user": {"id": 42}}]}
|
|
),
|
|
"aandt_api_matches": AandtApiMatchesResponse200.from_dict(
|
|
{"matches": matches}
|
|
),
|
|
}
|
|
with ExitStack() as stack:
|
|
for module, parsed in responses.items():
|
|
stack.enter_context(
|
|
mock.patch(
|
|
f"{api}.{module}.sync_detailed",
|
|
return_value=mock.Mock(parsed=parsed),
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
mock.patch(f"{api}._push_submission_link", return_value=[])
|
|
)
|
|
stack.enter_context(mock.patch("submissions.signals.get_channel_layer"))
|
|
send = stack.enter_context(mock.patch("submissions.signals.async_to_sync"))
|
|
with self.captureOnCommitCallbacks(execute=True):
|
|
sync_event_game_scopes(self.event_game)
|
|
|
|
return [c.args[1]["scope"] for c in send.return_value.call_args_list]
|
|
|
|
@staticmethod
|
|
def _match(match_id, status):
|
|
return {
|
|
"id": match_id,
|
|
"title": f"M{match_id}",
|
|
"status": status,
|
|
"players": {"1": 0},
|
|
}
|
|
|
|
def test_match_done_and_next_one_playing_notifies_player(self):
|
|
self._sync([self._match(1, "playing"), self._match(2, "waitPlayer")])
|
|
|
|
sent = self._sync([self._match(1, "done"), self._match(2, "playing")])
|
|
|
|
by_external_id = {
|
|
Scope.objects.get(id=scope["id"]).external_id: scope for scope in sent
|
|
}
|
|
self.assertFalse(by_external_id[1]["is_active"])
|
|
self.assertTrue(by_external_id[2]["is_active"])
|
|
self.assertTrue(by_external_id[2]["assigned"])
|
|
|
|
def test_unchanged_scopes_send_nothing(self):
|
|
self._sync([self._match(1, "playing")])
|
|
|
|
self.assertEqual(self._sync([self._match(1, "playing")]), [])
|
|
|
|
def test_removed_match_is_closed_and_notified(self):
|
|
self._sync([self._match(1, "playing")])
|
|
|
|
sent = self._sync([])
|
|
|
|
self.assertEqual(len(sent), 1)
|
|
self.assertFalse(sent[0]["is_active"])
|
|
|
|
|
|
class ScopeAdminBroadcastTests(TestCase):
|
|
def setUp(self):
|
|
event = Event.objects.create(name="Event", path="event")
|
|
game = Game.objects.create(name="Game", path="game")
|
|
event_game = EventGame.objects.create(event=event, game=game)
|
|
self.player = CustomUser.objects.create_user(username="player", password="x")
|
|
self.scope = Scope.objects.create(event_game=event_game, name="Match 1")
|
|
self.scope.assigned_users.set([self.player])
|
|
self.client.force_login(
|
|
CustomUser.objects.create_user(
|
|
username="admin", password="x", is_staff=True, is_superuser=True
|
|
)
|
|
)
|
|
self.form = {
|
|
"event_game": event_game.id,
|
|
"name": "Match 1",
|
|
"assigned_users": [self.player.id],
|
|
"is_active": "on",
|
|
}
|
|
|
|
def _sent(self, request):
|
|
with (
|
|
mock.patch("submissions.signals.get_channel_layer"),
|
|
mock.patch("submissions.signals.async_to_sync") as send,
|
|
self.captureOnCommitCallbacks(execute=True),
|
|
):
|
|
response = request()
|
|
self.assertEqual(response.status_code, 302)
|
|
return [c.args[1]["scope"] for c in send.return_value.call_args_list]
|
|
|
|
def _change(self, **changes):
|
|
url = f"/admin/submissions/scope/{self.scope.id}/change/"
|
|
data = {k: v for k, v in {**self.form, **changes}.items() if v is not None}
|
|
return self._sent(lambda: self.client.post(url, data))
|
|
|
|
def test_closing_scope_notifies_player(self):
|
|
sent = self._change(is_active=None)
|
|
|
|
self.assertEqual(
|
|
sent, [{"id": str(self.scope.id), "is_active": False, "assigned": True}]
|
|
)
|
|
|
|
def test_unassigning_player_notifies_them(self):
|
|
sent = self._change(assigned_users=[])
|
|
|
|
self.assertEqual(
|
|
sent, [{"id": str(self.scope.id), "is_active": True, "assigned": False}]
|
|
)
|
|
|
|
def test_unchanged_save_sends_nothing(self):
|
|
self.assertEqual(self._change(name="Renamed"), [])
|
|
|
|
def test_delete_notifies_player(self):
|
|
url = f"/admin/submissions/scope/{self.scope.id}/delete/"
|
|
|
|
sent = self._sent(lambda: self.client.post(url, {"post": "yes"}))
|
|
|
|
self.assertEqual(
|
|
sent, [{"id": str(self.scope.id), "is_active": False, "assigned": False}]
|
|
)
|