feat(submissions): auto-replace player in scopes, cancel sync + info about the sync
This commit is contained in:
@@ -1,14 +1,23 @@
|
||||
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 .services.aandt_sync import ScopeSyncError, ScopeSyncResult
|
||||
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):
|
||||
@@ -59,6 +68,42 @@ class SyncScopesApiTests(TestCase):
|
||||
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()
|
||||
@@ -76,21 +121,38 @@ class SyncEventGameScopesTaskTests(TestCase):
|
||||
)
|
||||
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, self.guard_key)
|
||||
self.addCleanup(
|
||||
cache.delete_many, [self.guard_key, self.job_key, self.state_key]
|
||||
)
|
||||
|
||||
def _run(self, status=None, exc=None):
|
||||
@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=exc,
|
||||
return_value=ScopeSyncResult(status=status),
|
||||
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
|
||||
|
||||
@@ -98,6 +160,7 @@ class SyncEventGameScopesTaskTests(TestCase):
|
||||
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))
|
||||
|
||||
@@ -114,6 +177,45 @@ class SyncEventGameScopesTaskTests(TestCase):
|
||||
|
||||
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")
|
||||
|
||||
@@ -127,3 +229,142 @@ class SyncEventGameScopesTaskTests(TestCase):
|
||||
|
||||
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}]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user