Repository: aiarena/aiarena-web
Pull request #929: Toggle competition participation tests & mutate helper bugg fix
Author: login: DrillableBit
Created: 2025-03-19T12:49:17Z | Merged: 2025-03-22T12:56:31Z



Diff:
diff --git a/aiarena/conftest.py b/aiarena/conftest.py
index 6d7f9bf0b..6c794897c 100644
--- a/aiarena/conftest.py
+++ b/aiarena/conftest.py
@@ -1,6 +1,6 @@
 import pytest
 
-from aiarena.core.models import Bot, WebsiteUser
+from aiarena.core.models import Bot, WebsiteUser, Competition, Game, GameMode
 from aiarena.core.models.bot_race import BotRace
 from aiarena.core.tests.base import BrowserHelper
 
@@ -53,3 +53,24 @@ def bot(db, user, all_bot_races):
         bot_data_publicly_downloadable=False,
         plays_race=BotRace.terran(),
     )
+
+@pytest.fixture
+def game(db):
+    return Game.objects.create(
+        name="Starcraft 2",
+    )
+
+@pytest.fixture
+def game_mode(db, game):
+    return GameMode.objects.create(
+        name = "melee",
+        game = game,
+    )
+
+
+@pytest.fixture
+def competition(db, game_mode):
+    return Competition.objects.create(
+        name= "AI Arena SC2 Grand Prix",
+        game_mode=game_mode,
+    )
\ No newline at end of file
diff --git a/aiarena/core/tests/base.py b/aiarena/core/tests/base.py
index 39b54c490..82f559fa8 100644
--- a/aiarena/core/tests/base.py
+++ b/aiarena/core/tests/base.py
@@ -90,16 +90,20 @@ def query(
         assert response.status_code == expected_status, (
             f"Unexpected response status code: {response.status_code}\n" f"Response content: {response.content}"
         )
-
+        
         content = json.loads(response.content)
         error_messages = [error["message"] for error in content.get("errors", [])]
+        found = []
 
         for message in expected_errors_like:
             for error in error_messages:
                 if message in error:
+                    found.append(error)
                     break
             else:
                 raise ValueError(f"Unexpected errors: {error_messages}\nResponse content: {content}")
+        unexpected = set(error_messages)-set(found)
+        assert not unexpected, (f"Unexpected errors: {unexpected}")
 
         if response.status_code == HTTPStatus.OK.value:
             return json.loads(response.content)["data"]
diff --git a/aiarena/graphql/mutations.py b/aiarena/graphql/mutations.py
index fd83e8547..ece7038a1 100644
--- a/aiarena/graphql/mutations.py
+++ b/aiarena/graphql/mutations.py
@@ -1,11 +1,72 @@
 from django.contrib.auth import authenticate, login, logout
 
+from django.core.exceptions import ValidationError
+
 import graphene
 from graphene_django.types import ErrorType
 
+from aiarena.core.models.competition_participation import CompetitionParticipation
+from aiarena.core.models.competition import Competition
 from aiarena.core.models.bot import Bot
 from aiarena.graphql.common import CleanedInputMutation, CleanedInputType, raise_for_access
-from aiarena.graphql.types import BotType
+from aiarena.graphql.types import BotType, CompetitionParticipationType, CompetitionType
+
+class ToggleCompetitionParticipationInput(CleanedInputType):
+    bot : Bot = graphene.ID()
+    competition : Competition = graphene.ID()
+
+    class Meta:
+        required_fields = ["bot", "competition"]
+    
+    @staticmethod
+    def clean_bot(bot, info):
+        try:
+            return graphene.Node.get_node_from_global_id(info=info, global_id=bot, only_type=BotType)
+        except Exception as e:
+            raise ValidationError(e)
+        
+    
+    @staticmethod
+    def clean_competition(competition, info):
+        try:
+            competition : Competition = graphene.Node.get_node_from_global_id(info=info, global_id=competition, only_type=CompetitionType)
+        except Exception as e:
+            raise ValidationError(e)
+        
+        if competition.status in ["closing", "closed"]:
+            raise ValidationError("This competition is closed.")
+        return competition
+    
+class ToggleCompetitionParticipation(CleanedInputMutation):
+    competition_participation = graphene.Field(CompetitionParticipationType)
+
+    class Meta: 
+        input_class = ToggleCompetitionParticipationInput
+
+    @classmethod
+    def perform_mutate(cls, info:graphene.ResolveInfo, input_object: ToggleCompetitionParticipationInput):
+  
+        raise_for_access(info, input_object.bot)
+        try: 
+            competition_participation : CompetitionParticipation = input_object.competition.participations.get(bot = input_object.bot)        
+
+        except CompetitionParticipation.DoesNotExist:
+            competition_participation = None
+
+        if competition_participation is None: 
+            competition_participation = input_object.competition.participations.create(bot=input_object.bot)
+
+        elif competition_participation.active:
+            competition_participation.division_num = CompetitionParticipation.DEFAULT_DIVISION
+            competition_participation.active = False
+            competition_participation.save()
+
+        else:
+            competition_participation.active = True
+            competition_participation.save()
+
+        return cls(errors=[], competition_participation=competition_participation)
+
 
 
 class UpdateBotInput(CleanedInputType):
@@ -91,5 +152,6 @@ def mutate(self, info: graphene.ResolveInfo) -> "SignOut":
 
 class Mutation(graphene.ObjectType):
     update_bot = UpdateBot.Field()
+    toggle_competition_participation = ToggleCompetitionParticipation.Field()
     password_sign_in = PasswordSignIn.Field()
     sign_out = SignOut.Field()
diff --git a/aiarena/graphql/tests/test_mutations.py b/aiarena/graphql/tests/test_mutations.py
index ae9c022f3..678492081 100644
--- a/aiarena/graphql/tests/test_mutations.py
+++ b/aiarena/graphql/tests/test_mutations.py
@@ -1,5 +1,128 @@
 from aiarena.core.tests.base import GraphQLTest
-from aiarena.graphql import BotType
+from aiarena.graphql import BotType, CompetitionType
+from aiarena.core.models import CompetitionParticipation
+
+class TestToggleCompetitionParticipation(GraphQLTest):
+    mutation_name = "toggleCompetitionParticipation"
+    mutation = """
+        mutation ($input: ToggleCompetitionParticipationInput!) {
+            toggleCompetitionParticipation(input: $input) {
+                errors {
+                    messages
+                    field
+                }
+            }
+        }
+    """
+
+    def test_happy_path(self, bot, competition):
+        
+        """
+        Test creating a competition participation.
+        """
+        # Make sure there are no existing competition participations
+        assert not CompetitionParticipation.objects.filter(bot=bot, competition=competition).exists()
+        
+        # Create a competition participation
+        self.mutate(
+            login_user=bot.user,
+            expected_status=200,
+            variables={
+                "input": {
+                    "bot": self.to_global_id(BotType, bot.id),
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+        )
+
+        # Verify competition participation was created correctly
+        competition_participation = CompetitionParticipation.objects.get(bot=bot, competition=competition)
+        assert competition_participation.active
+        assert competition_participation.division_num == CompetitionParticipation.DEFAULT_DIVISION
+
+        competition_participation.division_num = 1
+        competition_participation.save()
+                
+        # Toggle leave the competition i.e. set active to false, and make sure division is reset to 0
+        self.mutate(
+            login_user=bot.user,
+            expected_status=200,
+            variables={
+                "input": {
+                    "bot": self.to_global_id(BotType, bot.id),
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+        )
+
+        competition_participation.refresh_from_db()
+        assert not competition_participation.active 
+        assert competition_participation.division_num == CompetitionParticipation.DEFAULT_DIVISION
+    
+        # Toggle reactivate the competition i.e. set active to true
+        self.mutate(
+            login_user=bot.user,
+            expected_status=200,
+            variables={
+                "input": {
+                    "bot": self.to_global_id(BotType, bot.id),
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+        )
+
+        competition_participation.refresh_from_db()
+        assert competition_participation.active    
+       
+
+    def test_required_field_not_specified(self, competition, bot):
+        assert not CompetitionParticipation.objects.filter(bot=bot, competition=competition).exists()
+        
+        # Create a competition participation
+        self.mutate(
+            login_user=bot.user,
+            expected_status=200,
+            variables={
+                "input": {             
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+            expected_validation_errors={"bot": ["Required field"]},
+        )
+     
+    def test_invalid_id(self, competition, bot):
+        assert not CompetitionParticipation.objects.filter(bot=bot, competition=competition).exists()
+        
+        # Create a competition participation
+        self.mutate(
+            login_user=bot.user,
+            expected_status=200,
+            variables={
+                "input": {             
+                    "bot": "Abracadabra",
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+            expected_validation_errors={"bot": ['Unable to parse global ID "Abracadabra". Make sure it is a base64 encoded string in the format: "TypeName:id". Exception message: Invalid Global ID']},
+        )        
+
+    def test_update_competition_participation_unauthorized(self, competition, bot, other_user):
+        assert not CompetitionParticipation.objects.filter(bot=bot, competition=competition).exists()
+        
+        # Create a competition participation with other_user.
+        self.mutate(
+            login_user=other_user,
+            expected_status=200,
+            variables={
+                "input": {
+                    "bot": self.to_global_id(BotType, bot.id),
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+            expected_errors_like=['bobby cannot perform "write" on "My Bot"']
+        )
+        # Verify the competition participation was not created.
+        assert not CompetitionParticipation.objects.filter(bot=bot, competition=competition).exists()
 
 
 class TestUpdateBot(GraphQLTest):


Reviews:
user: login: lladdy
body: Great work @DrillableBit 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-22T03:18:44Z

Review comments:
diff_hunk: @@ -1,11 +1,72 @@
 from django.contrib.auth import authenticate, login, logout
 
+from django.core.exceptions import ValidationError
+
 import graphene
 from graphene_django.types import ErrorType
 
+from aiarena.core.models.competition_participation import CompetitionParticipation
+from aiarena.core.models.competition import Competition
 from aiarena.core.models.bot import Bot
 from aiarena.graphql.common import CleanedInputMutation, CleanedInputType, raise_for_access
-from aiarena.graphql.types import BotType
+from aiarena.graphql.types import BotType, CompetitionParticipationType, CompetitionType
+
+class ToggleCompetitionParticipationInput(CleanedInputType):
+    bot : Bot = graphene.ID()
+    competition : Competition = graphene.ID()
+
+    class Meta:
+        required_fields = ["bot", "competition"]
+    
+    @staticmethod
+    def clean_bot(bot, info):
+        try:
+            return graphene.Node.get_node_from_global_id(info=info, global_id=bot, only_type=BotType)
+        except Exception as e:
+            raise ValidationError(e)
path: aiarena/graphql/mutations.py
user: login: lladdy
body: I suggest if we're going to re-throw this, we add extra context. e.g. "Failed to get node from global ID."

Also consider if `ValidationError` is an inappropriate type because we're not actually doing validation here, and we don't know what type of error we've caught.
created_at: 2025-03-22T01:47:38Z
author_association: MEMBER
diff_hunk: @@ -1,11 +1,72 @@
 from django.contrib.auth import authenticate, login, logout
 
+from django.core.exceptions import ValidationError
+
 import graphene
 from graphene_django.types import ErrorType
 
+from aiarena.core.models.competition_participation import CompetitionParticipation
+from aiarena.core.models.competition import Competition
 from aiarena.core.models.bot import Bot
 from aiarena.graphql.common import CleanedInputMutation, CleanedInputType, raise_for_access
-from aiarena.graphql.types import BotType
+from aiarena.graphql.types import BotType, CompetitionParticipationType, CompetitionType
+
+class ToggleCompetitionParticipationInput(CleanedInputType):
+    bot : Bot = graphene.ID()
+    competition : Competition = graphene.ID()
+
+    class Meta:
+        required_fields = ["bot", "competition"]
+    
+    @staticmethod
+    def clean_bot(bot, info):
+        try:
+            return graphene.Node.get_node_from_global_id(info=info, global_id=bot, only_type=BotType)
+        except Exception as e:
+            raise ValidationError(e)
+        
+    
+    @staticmethod
+    def clean_competition(competition, info):
+        try:
+            competition : Competition = graphene.Node.get_node_from_global_id(info=info, global_id=competition, only_type=CompetitionType)
+        except Exception as e:
+            raise ValidationError(e)
path: aiarena/graphql/mutations.py
user: login: lladdy
body: Same as above comment.
created_at: 2025-03-22T01:52:47Z
author_association: MEMBER
diff_hunk: @@ -1,11 +1,72 @@
 from django.contrib.auth import authenticate, login, logout
 
+from django.core.exceptions import ValidationError
+
 import graphene
 from graphene_django.types import ErrorType
 
+from aiarena.core.models.competition_participation import CompetitionParticipation
+from aiarena.core.models.competition import Competition
 from aiarena.core.models.bot import Bot
 from aiarena.graphql.common import CleanedInputMutation, CleanedInputType, raise_for_access
-from aiarena.graphql.types import BotType
+from aiarena.graphql.types import BotType, CompetitionParticipationType, CompetitionType
+
+class ToggleCompetitionParticipationInput(CleanedInputType):
+    bot : Bot = graphene.ID()
+    competition : Competition = graphene.ID()
+
+    class Meta:
+        required_fields = ["bot", "competition"]
+    
+    @staticmethod
+    def clean_bot(bot, info):
+        try:
+            return graphene.Node.get_node_from_global_id(info=info, global_id=bot, only_type=BotType)
+        except Exception as e:
+            raise ValidationError(e)
+        
+    
+    @staticmethod
+    def clean_competition(competition, info):
+        try:
+            competition : Competition = graphene.Node.get_node_from_global_id(info=info, global_id=competition, only_type=CompetitionType)
+        except Exception as e:
+            raise ValidationError(e)
+        
+        if competition.status in ["closing", "closed"]:
path: aiarena/graphql/mutations.py
user: login: lladdy
body: Good validation checking. I suggest moving this into the business logic code.
e.g. have a method such as `competition.participants_can_modify_enrollment()` that returns a boolean. Feel free to think of a better name.
created_at: 2025-03-22T01:57:49Z
author_association: MEMBER
diff_hunk: @@ -1,11 +1,72 @@
 from django.contrib.auth import authenticate, login, logout
 
+from django.core.exceptions import ValidationError
+
 import graphene
 from graphene_django.types import ErrorType
 
+from aiarena.core.models.competition_participation import CompetitionParticipation
+from aiarena.core.models.competition import Competition
 from aiarena.core.models.bot import Bot
 from aiarena.graphql.common import CleanedInputMutation, CleanedInputType, raise_for_access
-from aiarena.graphql.types import BotType
+from aiarena.graphql.types import BotType, CompetitionParticipationType, CompetitionType
+
+class ToggleCompetitionParticipationInput(CleanedInputType):
+    bot : Bot = graphene.ID()
+    competition : Competition = graphene.ID()
+
+    class Meta:
+        required_fields = ["bot", "competition"]
+    
+    @staticmethod
+    def clean_bot(bot, info):
+        try:
+            return graphene.Node.get_node_from_global_id(info=info, global_id=bot, only_type=BotType)
+        except Exception as e:
+            raise ValidationError(e)
+        
+    
+    @staticmethod
+    def clean_competition(competition, info):
+        try:
+            competition : Competition = graphene.Node.get_node_from_global_id(info=info, global_id=competition, only_type=CompetitionType)
+        except Exception as e:
+            raise ValidationError(e)
+        
+        if competition.status in ["closing", "closed"]:
+            raise ValidationError("This competition is closed.")
+        return competition
+    
+class ToggleCompetitionParticipation(CleanedInputMutation):
+    competition_participation = graphene.Field(CompetitionParticipationType)
+
+    class Meta: 
+        input_class = ToggleCompetitionParticipationInput
+
+    @classmethod
+    def perform_mutate(cls, info:graphene.ResolveInfo, input_object: ToggleCompetitionParticipationInput):
+  
+        raise_for_access(info, input_object.bot)
+        try: 
+            competition_participation : CompetitionParticipation = input_object.competition.participations.get(bot = input_object.bot)        
+
+        except CompetitionParticipation.DoesNotExist:
+            competition_participation = None
+
+        if competition_participation is None: 
+            competition_participation = input_object.competition.participations.create(bot=input_object.bot)
path: aiarena/graphql/mutations.py
user: login: lladdy
body: This can be moved into the except clause.
created_at: 2025-03-22T02:04:36Z
author_association: MEMBER
diff_hunk: @@ -1,11 +1,72 @@
 from django.contrib.auth import authenticate, login, logout
 
+from django.core.exceptions import ValidationError
+
 import graphene
 from graphene_django.types import ErrorType
 
+from aiarena.core.models.competition_participation import CompetitionParticipation
+from aiarena.core.models.competition import Competition
 from aiarena.core.models.bot import Bot
 from aiarena.graphql.common import CleanedInputMutation, CleanedInputType, raise_for_access
-from aiarena.graphql.types import BotType
+from aiarena.graphql.types import BotType, CompetitionParticipationType, CompetitionType
+
+class ToggleCompetitionParticipationInput(CleanedInputType):
+    bot : Bot = graphene.ID()
+    competition : Competition = graphene.ID()
+
+    class Meta:
+        required_fields = ["bot", "competition"]
+    
+    @staticmethod
+    def clean_bot(bot, info):
+        try:
+            return graphene.Node.get_node_from_global_id(info=info, global_id=bot, only_type=BotType)
+        except Exception as e:
+            raise ValidationError(e)
+        
+    
+    @staticmethod
+    def clean_competition(competition, info):
+        try:
+            competition : Competition = graphene.Node.get_node_from_global_id(info=info, global_id=competition, only_type=CompetitionType)
+        except Exception as e:
+            raise ValidationError(e)
+        
+        if competition.status in ["closing", "closed"]:
+            raise ValidationError("This competition is closed.")
+        return competition
+    
+class ToggleCompetitionParticipation(CleanedInputMutation):
+    competition_participation = graphene.Field(CompetitionParticipationType)
+
+    class Meta: 
+        input_class = ToggleCompetitionParticipationInput
+
+    @classmethod
+    def perform_mutate(cls, info:graphene.ResolveInfo, input_object: ToggleCompetitionParticipationInput):
+  
+        raise_for_access(info, input_object.bot)
+        try: 
+            competition_participation : CompetitionParticipation = input_object.competition.participations.get(bot = input_object.bot)        
+
+        except CompetitionParticipation.DoesNotExist:
+            competition_participation = None
+
+        if competition_participation is None: 
+            competition_participation = input_object.competition.participations.create(bot=input_object.bot)
+
+        elif competition_participation.active:
path: aiarena/graphql/mutations.py
user: login: lladdy
body: If the above statement is moved into the except clause, then these 2 sections (elif, else) can be combined to an else block:
```python
    try:
        ... # if this doesn't error, it goes to the else block
    except:
        ...
    else:
        competition_participation.active = not competition_participation.active
        if not competition_participation.active:
            competition_participation.division_num = CompetitionParticipation.DEFAULT_DIVISION
        competition_participation.save()
```
created_at: 2025-03-22T02:25:33Z
author_association: MEMBER
diff_hunk: @@ -91,5 +152,6 @@ def mutate(self, info: graphene.ResolveInfo) -> "SignOut":
 
 class Mutation(graphene.ObjectType):
     update_bot = UpdateBot.Field()
+    toggle_competition_participation = ToggleCompetitionParticipation.Field()
path: aiarena/graphql/mutations.py
user: login: lladdy
body: Consider instead of having a "toggle", having 2 separate operations:
- one for activation
- another for deactivation

This is a minor thing in this scenario, but I find generally good practice.

A downside is 2 mutations instead of 1. The upside is they're more explicit and the mutation can validate that the action taken is what the user intended.

e.g.
Assume the user loads the frontend and it reflects the bot being `active`. Then a match crash result is submitted for the bot - it triggers the crash limit and the bot is deactivated. If the user then tries to toggle the bot `active` state, thinking they're deactivating it, they will instead unintentionally re-activate the bot.
created_at: 2025-03-22T03:04:21Z
author_association: MEMBER
diff_hunk: @@ -1,5 +1,128 @@
 from aiarena.core.tests.base import GraphQLTest
-from aiarena.graphql import BotType
+from aiarena.graphql import BotType, CompetitionType
+from aiarena.core.models import CompetitionParticipation
+
+class TestToggleCompetitionParticipation(GraphQLTest):
+    mutation_name = "toggleCompetitionParticipation"
+    mutation = """
+        mutation ($input: ToggleCompetitionParticipationInput!) {
+            toggleCompetitionParticipation(input: $input) {
+                errors {
+                    messages
+                    field
+                }
+            }
+        }
+    """
+
+    def test_happy_path(self, bot, competition):
+        
+        """
+        Test creating a competition participation.
+        """
+        # Make sure there are no existing competition participations
+        assert not CompetitionParticipation.objects.filter(bot=bot, competition=competition).exists()
+        
+        # Create a competition participation
+        self.mutate(
+            login_user=bot.user,
+            expected_status=200,
+            variables={
+                "input": {
+                    "bot": self.to_global_id(BotType, bot.id),
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+        )
+
+        # Verify competition participation was created correctly
+        competition_participation = CompetitionParticipation.objects.get(bot=bot, competition=competition)
+        assert competition_participation.active
+        assert competition_participation.division_num == CompetitionParticipation.DEFAULT_DIVISION
+
+        competition_participation.division_num = 1
+        competition_participation.save()
+                
+        # Toggle leave the competition i.e. set active to false, and make sure division is reset to 0
+        self.mutate(
+            login_user=bot.user,
+            expected_status=200,
+            variables={
+                "input": {
+                    "bot": self.to_global_id(BotType, bot.id),
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+        )
+
+        competition_participation.refresh_from_db()
+        assert not competition_participation.active 
+        assert competition_participation.division_num == CompetitionParticipation.DEFAULT_DIVISION
+    
+        # Toggle reactivate the competition i.e. set active to true
+        self.mutate(
+            login_user=bot.user,
+            expected_status=200,
+            variables={
+                "input": {
+                    "bot": self.to_global_id(BotType, bot.id),
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+        )
+
+        competition_participation.refresh_from_db()
+        assert competition_participation.active    
+       
+
+    def test_required_field_not_specified(self, competition, bot):
+        assert not CompetitionParticipation.objects.filter(bot=bot, competition=competition).exists()
+        
+        # Create a competition participation
+        self.mutate(
+            login_user=bot.user,
+            expected_status=200,
+            variables={
+                "input": {             
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+            expected_validation_errors={"bot": ["Required field"]},
+        )
+     
+    def test_invalid_id(self, competition, bot):
+        assert not CompetitionParticipation.objects.filter(bot=bot, competition=competition).exists()
+        
+        # Create a competition participation
+        self.mutate(
+            login_user=bot.user,
+            expected_status=200,
+            variables={
+                "input": {             
+                    "bot": "Abracadabra",
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+            expected_validation_errors={"bot": ['Unable to parse global ID "Abracadabra". Make sure it is a base64 encoded string in the format: "TypeName:id". Exception message: Invalid Global ID']},
+        )        
+
+    def test_update_competition_participation_unauthorized(self, competition, bot, other_user):
+        assert not CompetitionParticipation.objects.filter(bot=bot, competition=competition).exists()
+        
+        # Create a competition participation with other_user.
+        self.mutate(
+            login_user=other_user,
+            expected_status=200,
+            variables={
+                "input": {
+                    "bot": self.to_global_id(BotType, bot.id),
+                    "competition": self.to_global_id(CompetitionType, competition.id),
+                }
+            },
+            expected_errors_like=['bobby cannot perform "write" on "My Bot"']
path: aiarena/graphql/tests/test_mutations.py
user: login: lladdy
body: poor bobby :(
created_at: 2025-03-22T03:10:40Z
author_association: MEMBER
diff_hunk: @@ -90,16 +90,20 @@ def query(
         assert response.status_code == expected_status, (
             f"Unexpected response status code: {response.status_code}\n" f"Response content: {response.content}"
         )
-
+        
         content = json.loads(response.content)
         error_messages = [error["message"] for error in content.get("errors", [])]
+        found = []
 
         for message in expected_errors_like:
             for error in error_messages:
                 if message in error:
+                    found.append(error)
                     break
             else:
                 raise ValueError(f"Unexpected errors: {error_messages}\nResponse content: {content}")
+        unexpected = set(error_messages)-set(found)
+        assert not unexpected, (f"Unexpected errors: {unexpected}")
path: aiarena/core/tests/base.py
user: login: lladdy
body: Just flagging to check if this is required - I note there's already an `Unexpected errors` exception raised above.
created_at: 2025-03-22T03:16:09Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-19T12:51:37Z
body: ## Playwright test Report
2 tests  ±0   2 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.8.0/README.md#the-symbols "passed tests") ±0   14s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.8.0/README.md#the-symbols "duration of all tests") ±0s
1 suites ±0   0 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.8.0/README.md#the-symbols "skipped / disabled tests") ±0 
1 files   ±0   0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.8.0/README.md#the-symbols "failed tests") ±0 

Results for commit 340a1666. ± Comparison against base commit a633f55d.

[test-results]:data:application/gzip;base64,H4sIANa92mcC/1WMyw7CIBBFf6Vh7aIURPRnzDCFZGJbDI+V8d+Faivd3XNucl7M0WQju3X81LGYKe0w5gCJ/FJRFi5Pqt+w7XvMiEfxoGcR/S4c0HQQNgQffibkZevV2ea+/K+t3MRWblvo55lSASZkD1wpJbQUGjjAGa0wl8FIN3INWlu4ckDD3h8JhPUk/AAAAA==

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-19T12:53:49Z
body: ## Test Report
122 tests  +4   122 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.8.0/README.md#the-symbols "passed tests") +4   3m 11s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.8.0/README.md#the-symbols "duration of all tests") -41s
    1 suites ±0       0 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.8.0/README.md#the-symbols "skipped / disabled tests") ±0 
    1 files   ±0       0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.8.0/README.md#the-symbols "failed tests") ±0 

Results for commit 340a1666. ± Comparison against base commit a633f55d.

[test-results]:data:application/gzip;base64,H4sIAFu+2mcC/12MSw7CIBQAr9KwdlE+IvUy5kEhebEths/KeHcBtR+XM5PMkzicbCTXjp46EjOmFcYcIKFfKg5VlJRaZOxHt5iN+Vd3fBTVr8IBTgdhQ/Dha0JetmeF4/JjtmPj3bDx/mf8PGMqQLjogUopuRJcAQU4G8v1hWnhRqpAKQsDBaPJ6w0B+ZwwBQEAAA==

author_association: NONE