From a7f5cab977a2d82a6e91c705dc6140b7849b82ed Mon Sep 17 00:00:00 2001 From: Paul Philion Date: Tue, 5 Mar 2024 14:10:30 -0800 Subject: [PATCH] test stability once more, after completely refactoring tests base classes --- cog_tickets.py | 1 + imap.py | 17 +++++------- redmine.py | 35 ++++++++++++----------- session.py | 26 ++++++++++-------- test_cog_scn.py | 14 +++++----- test_cog_tickets.py | 2 +- test_netbot.py | 7 ++--- test_redmine.py | 28 ++++++++++--------- test_utils.py | 67 +++++++++++---------------------------------- tickets.py | 51 ++++++++++++++++------------------ users.py | 17 ++++++------ 11 files changed, 115 insertions(+), 150 deletions(-) diff --git a/cog_tickets.py b/cog_tickets.py index f7d849b..ee79d69 100644 --- a/cog_tickets.py +++ b/cog_tickets.py @@ -165,6 +165,7 @@ async def thread_ticket(self, ctx: discord.ApplicationContext, ticket_id:int): # TODO message templates note = f"Created Discord thread: {thread.name}: {thread.jump_url}" user = self.redmine.user_mgr.find_discord_user(ctx.user.name) + log.debug(f">>> found {user} for {ctx.user.name}") self.redmine.enable_discord_sync(ticket.id, user, note) await ctx.respond(f"Created new thread for {ticket.id}: {thread}") # todo add some fancy formatting diff --git a/imap.py b/imap.py index fd42d6f..33a1f90 100755 --- a/imap.py +++ b/imap.py @@ -45,6 +45,11 @@ def set_token(self, token): class Message(): """email message""" + from_address: str + subject:str + attachments: list[Attachment] + note: str + def __init__(self, from_addr:str, subject:str): self.from_address = from_addr self.subject = subject @@ -251,16 +256,8 @@ def handle_message(self, msg_id:str, message:Message): log.info(f"Updated ticket #{ticket.id} with message from {user.login} and {len(message.attachments)} attachments") else: # no open tickets, create new ticket for the email message - self.redmine.create_ticket(user, subject, message.note, message.attachments) - log.info(f"Created new ticket for: {user.login}, {subject}, with {len(message.attachments)} attachments") - - """ - # check status - if self.user_mgr.is_blocked(user): - log.debug(f"Rejecting ticket #{ticket.id} based on blocked user {user.login}") - self.reject_ticket(ticket.id) - refresh. - """ + ticket = self.redmine.create_ticket(user, subject, message.note, message.attachments) + log.info(f"Created new ticket for: {ticket}, with {len(message.attachments)} attachments") def synchronize(self): diff --git a/redmine.py b/redmine.py index b2d7f0f..ffa59b5 100644 --- a/redmine.py +++ b/redmine.py @@ -28,23 +28,15 @@ class RedmineException(Exception): """redmine exception""" - def __init__(self, message: str, request_id: str) -> None: + def __init__(self, message: str, request_id: str = "-") -> None: super().__init__(message + ", req_id=" + request_id) self.request_id = request_id -class Client(): ## redmine.Client +class Client(): """redmine client""" - def __init__(self, url: str, token: str): - self.url = url - if self.url is None: - raise RedmineException("Unable to load REDMINE_URL", "[n/a]") - - self.token = token - if self.url is None: - raise RedmineException("Unable to load REDMINE_TOKEN", "__init__") - - session:RedmineSession = RedmineSession(url, token) + def __init__(self, session:RedmineSession): + self.url = session.url self.user_mgr:UserManager = UserManager(session) self.ticket_mgr:TicketManager = TicketManager(session) @@ -54,15 +46,26 @@ def __init__(self, url: str, token: str): @classmethod def fromenv(cls): url = os.getenv('REDMINE_URL') + if url is None: + raise RedmineException("Unable to load REDMINE_URL") + token = os.getenv('REDMINE_TOKEN') - return cls(url, token) + if token is None: + raise RedmineException("Unable to load REDMINE_TOKEN") + + return cls(RedmineSession(url, token)) - def create_ticket(self, user, subject, body, attachments=None): - return self.ticket_mgr.create(user, subject, body, attachments) + def create_ticket(self, user, subject, body, attachments=None) -> Ticket: + ticket = self.ticket_mgr.create(user, subject, body, attachments) + # check user status, reject the ticket if blocked + if self.user_mgr.is_blocked(user): + log.debug(f"Rejecting ticket #{ticket.id} based on blocked user {user.login}") + ticket = self.ticket_mgr.reject_ticket(ticket.id) + return ticket - def update_ticket(self, ticket_id:int, fields:dict, user_login:str=None): + def update_ticket(self, ticket_id:int, fields:dict, user_login:str|None=None): return self.ticket_mgr.update(ticket_id, fields, user_login) diff --git a/session.py b/session.py index c83cb69..6dcdcfe 100644 --- a/session.py +++ b/session.py @@ -6,8 +6,9 @@ import logging from urllib3.exceptions import ConnectTimeoutError import requests -from requests.exceptions import ConnectTimeout, ConnectionError +from requests.exceptions import ConnectTimeout +import dotenv log = logging.getLogger(__name__) @@ -47,8 +48,12 @@ def fromenv(cls): return cls(url, token) + @classmethod + def fromenvfile(cls): + dotenv.load_dotenv() + return cls.fromenv() - def get_headers(self, impersonate_id:str=None): + def get_headers(self, impersonate_id:str|None=None): headers = { 'User-Agent': 'netbot/0.0.1', # TODO update to project version, and add version management 'Content-Type': 'application/json', @@ -62,11 +67,9 @@ def get_headers(self, impersonate_id:str=None): return headers - def get(self, query_str:str, user:str=None): + def get(self, query_str:str, impersonate_id:str|None=None): """run a query against a redmine instance""" - - headers = self.get_headers(user) - + headers = self.get_headers(impersonate_id) try: r = self.session.get(f"{self.url}{query_str}", headers=headers, timeout=TIMEOUT) @@ -83,19 +86,18 @@ def get(self, query_str:str, user:str=None): return None - # data=json.dumps(data), - def put(self, resource: str, data:dict, user_login: str = None) -> None: + def put(self, resource: str, data:str, impersonate_id:str|None=None) -> None: r = self.session.put(f"{self.url}{resource}", data=data, timeout=TIMEOUT, - headers=self.get_headers(user_login)) + headers=self.get_headers(impersonate_id)) if r.ok: - log.debug(f"PUT {resource}: {data} - {r}") + log.debug(f"PUT {resource}: {data}") else: - raise RedmineException(f"POST {resource} by {user_login} failed, status=[{r.status_code}] {r.reason}", r.headers['X-Request-Id']) + raise RedmineException(f"POST {resource} by {impersonate_id} failed, status=[{r.status_code}] {r.reason}", r.headers['X-Request-Id']) - def post(self, resource: str, data:dict = None, user_login: str = None, files = None) -> dict: + def post(self, resource: str, data:str, user_login: str|None = None, files: list|None = None) -> dict|None: r = self.session.post(f"{self.url}{resource}", data=data, files=files, diff --git a/test_cog_scn.py b/test_cog_scn.py index c31cc41..00c7bde 100755 --- a/test_cog_scn.py +++ b/test_cog_scn.py @@ -42,7 +42,7 @@ async def test_team_join_leave(self): # check add result #ctx.respond.assert_called_with( - # f"Discord user: {self.discord_user} has been paired with redmine user: {self.user.login}") + # f"Discord user: {self.user.discord_id} has been paired with redmine user: {self.user.login}") # reindex using cog ctx = self.build_context() @@ -52,7 +52,7 @@ async def test_team_join_leave(self): # 4.5 check reindex result, and lookup based on login and discord id ctx.respond.assert_called_with("Rebuilt redmine indices.") self.assertIsNotNone(self.redmine.user_mgr.find(self.user.login)) - self.assertIsNotNone(self.redmine.user_mgr.find(self.discord_user)) + self.assertIsNotNone(self.redmine.user_mgr.find(self.user.discord_id)) # join team users ctx = self.build_context() @@ -63,13 +63,13 @@ async def test_team_join_leave(self): # confirm via mock callback and API #ctx.respond.assert_called_with(f"Unknown team name: {test_team_name}") # unknown team response! - ctx.respond.assert_called_with(f"**{self.discord_user}** has joined *{test_team_name}*") + ctx.respond.assert_called_with(f"**{self.user.discord_id}** has joined *{test_team_name}*") self.assertTrue(self.redmine.user_mgr.is_user_in_team(self.user, test_team_name), f"{self.user.login} not in team {test_team_name}") # confirm in team via cog teams response ctx = self.build_context() await self.cog.teams(ctx, test_team_name) - self.assertIn(self.full_name, str(ctx.respond.call_args)) + self.assertIn(self.user.full_name(), str(ctx.respond.call_args)) # leave team users ctx = self.build_context() @@ -78,12 +78,12 @@ async def test_team_join_leave(self): # confirm via API and callback self.assertFalse(self.redmine.user_mgr.is_user_in_team(self.user, test_team_name), f"{self.user.login} *in* team {test_team_name}") - ctx.respond.assert_called_with(f"**{self.discord_user}** has left *{test_team_name}*") + ctx.respond.assert_called_with(f"**{self.user.discord_id}** has left *{test_team_name}*") # confirm not in team via cog teams response ctx = self.build_context() await self.cog.teams(ctx, test_team_name) - self.assertNotIn(self.full_name, str(ctx.respond.call_args)) + self.assertNotIn(self.user.full_name(), str(ctx.respond.call_args)) async def test_thread_sync(self): @@ -148,7 +148,7 @@ async def test_locked_during_sync_ticket(self): message = unittest.mock.AsyncMock(discord.Message) message.content = f"This is a new note about ticket #{ticket.id} for test {self.tag}" message.author = unittest.mock.AsyncMock(discord.Member) - message.author.name = self.discord_user + message.author.name = self.user.discord_id thread = unittest.mock.AsyncMock(discord.Thread) thread.name = f"Ticket #{ticket.id}: {ticket.subject}" diff --git a/test_cog_tickets.py b/test_cog_tickets.py index 6975125..1c56f50 100755 --- a/test_cog_tickets.py +++ b/test_cog_tickets.py @@ -115,7 +115,7 @@ async def test_thread_sync(self): thread.name = f"Ticket #{ticket.id}: {subject}" member = unittest.mock.AsyncMock(discord.Member) - member.name=self.discord_user + member.name=self.user.discord_id message = unittest.mock.AsyncMock(discord.Message) message.channel = ctx.channel diff --git a/test_netbot.py b/test_netbot.py index 332a857..b06643a 100755 --- a/test_netbot.py +++ b/test_netbot.py @@ -2,7 +2,6 @@ """NetBot Test Suite""" import unittest -from unittest import mock import logging import discord @@ -48,7 +47,7 @@ async def test_synchronize_ticket(self): message = unittest.mock.AsyncMock(discord.Message) message.content = f"This is a new note about ticket #{ticket.id} for test {self.tag}" message.author = unittest.mock.AsyncMock(discord.Member) - message.author.name = self.discord_user + message.author.name = self.user.discord_id thread = unittest.mock.AsyncMock(discord.Thread) thread.name = f"Ticket #{ticket.id}" @@ -62,7 +61,7 @@ async def test_synchronize_ticket(self): # assert method send called on mock thread, with the correct values self.assertIn(self.tag, thread.send.call_args.args[0]) - self.assertIn(self.full_name, thread.send.call_args.args[0]) + self.assertIn(self.user.full_name(), thread.send.call_args.args[0]) self.assertIn(body, thread.send.call_args.args[0]) # get notes from redmine, assert tags in most recent @@ -85,7 +84,7 @@ async def test_sync_ticket_long_message(self): message = unittest.mock.AsyncMock(discord.Message) message.content = f"This is a new note about ticket #{ticket.id} for test {self.tag}" message.author = unittest.mock.AsyncMock(discord.Member) - message.author.name = self.discord_user + message.author.name = self.user.discord_id thread = unittest.mock.AsyncMock(discord.Thread) thread.name = f"Ticket #{ticket.id}" diff --git a/test_redmine.py b/test_redmine.py index a5a59a1..bd01654 100755 --- a/test_redmine.py +++ b/test_redmine.py @@ -7,6 +7,7 @@ from dotenv import load_dotenv import redmine +import session import test_utils @@ -29,27 +30,28 @@ def test_block_user(self): self.user_mgr.unblock(self.user) self.assertFalse(self.user_mgr.is_blocked(self.user)) - """ + def test_blocked_create_ticket(self): - try: - # block - self.user_mgr.block(self.user) - self.assertTrue(self.redmine.user_mgr.is_blocked(self.user)) + # block + self.user_mgr.block(self.user) + self.assertTrue(self.user_mgr.is_blocked(self.user)) - # create ticket for blocked - ticket = self.create_ticket(self.user, "subject", "body") - self.assertEqual("Reject", ticket.status.name) + # create ticket for blocked + ticket = self.create_test_ticket() + self.assertIsNotNone(ticket) + self.assertEqual("Reject", ticket.status.name) - finally: - # remove the test user - self.redmine.user_mgr.remove(user) - """ + # remove the ticket and unbluck the user + self.tickets_mgr.remove(ticket.id) + self.user_mgr.unblock(self.user) + self.assertFalse(self.user_mgr.is_blocked(self.user)) def test_client_timeout(self): # construct an invalid client to try to get a timeout try: - client = redmine.Client("http://192.168.1.42/", "bad-token") + bad_session = session.RedmineSession("http://192.168.1.42/", "bad-token") + client = redmine.Client(bad_session) self.assertIsNotNone(client) #log.info(client) except Exception: diff --git a/test_utils.py b/test_utils.py index 8d7a458..cb68c5d 100755 --- a/test_utils.py +++ b/test_utils.py @@ -78,79 +78,44 @@ def remove_test_users(user_mgr:UserManager): class RedmineTestCase(unittest.TestCase): """Abstract base class for testing redmine features""" - user_mgr: UserManager - tickets_mgr: tickets.TicketManager - tag: str - user: User - @classmethod def setUpClass(cls): sess = session.RedmineSession.fromenv() - cls.user_mgr = UserManager(sess) - cls.tickets_mgr = tickets.TicketManager(sess) + cls.redmine = Client(sess) + cls.user_mgr = cls.redmine.user_mgr + cls.tickets_mgr = cls.redmine.ticket_mgr cls.tag:str = tagstr() cls.user:User = create_test_user(cls.user_mgr, cls.tag) + cls.user_mgr.cache.cache_user(cls.user) log.info(f"SETUP created test user: {cls.user}") - @classmethod def tearDownClass(cls): - cls.user_mgr.remove(cls.user) - log.info(f"TEARDOWN removed test user: {cls.user}") - - -class BotTestCase(unittest.IsolatedAsyncioTestCase): - """Abstract base class for testing Bot features""" - redmine: Client = None - usertag: str = None - user: User = None - - @classmethod - def setUpClass(cls): - log.info("Setting up test fixtures") - cls.redmine:Client = Client.fromenv() - cls.usertag:str = tagstr() - cls.user:User = create_test_user(cls.redmine.user_mgr, cls.usertag) - log.info(f"Created test user: {cls.user}") + if cls.user: + cls.user_mgr.remove(cls.user) + log.info(f"TEARDOWN removed test user: {cls.user}") + def create_test_ticket(self) -> tickets.Ticket: + subject = f"TEST {self.tag} {unittest.TestCase.id(self)}" + text = f"This is a ticket for {unittest.TestCase.id(self)} with {self.tag}." + ticket = self.redmine.create_ticket(self.user, subject, text) + return ticket - @classmethod - def tearDownClass(cls): - log.info(f"Tearing down test fixtures: {cls.user}") - cls.redmine.user_mgr.remove(cls.user) +class BotTestCase(RedmineTestCase, unittest.IsolatedAsyncioTestCase): + """Abstract base class for testing Bot features""" def build_context(self) -> ApplicationContext: ctx = mock.AsyncMock(ApplicationContext) ctx.user = mock.AsyncMock(discord.Member) - ctx.user.name = self.discord_user + ctx.user.name = self.user.discord_id ctx.command = mock.AsyncMock(discord.ApplicationCommand) ctx.command.name = unittest.TestCase.id(self) - log.debug(f"created ctx with {self.discord_user}: {ctx}") + log.debug(f"created ctx with {self.user.discord_id}: {ctx}") return ctx - def create_test_ticket(self): - subject = f"{unittest.TestCase.id(self)} {self.tag}" - text = f"This is a ticket for {unittest.TestCase.id(self)} with {self.tag}." - return self.redmine.create_ticket(self.user, subject, text) - - - def setUp(self): - self.tag = self.__class__.usertag # TODO just rename usertag to tag - represents the suite run - self.assertIsNotNone(self.tag) - self.assertIsNotNone(self.user) - - self.full_name = self.user.firstname + " " + self.user.lastname - self.discord_user = self.user.discord_id - - self.assertIsNotNone(self.redmine.user_mgr.find(self.user.login)) - self.assertIsNotNone(self.redmine.user_mgr.find(self.discord_user)) - - log.debug(f"setUp user {self.user.login} {self.discord_user}") - - if __name__ == '__main__': # when running this main, turn on DEBUG logging.basicConfig(level=logging.DEBUG, format="{asctime} {levelname:<8s} {name:<16} {message}", style='{') diff --git a/tickets.py b/tickets.py index 7a8dd39..3c56071 100644 --- a/tickets.py +++ b/tickets.py @@ -54,7 +54,7 @@ class TicketNote(): # https://www.redmine.org/projects/redmine/wiki/Rest_IssueJo created_on: dt.datetime private_notes: bool details: list[PropertyChange] - user: NamedId = None + user: NamedId | None = None def __post_init__(self): self.user = NamedId(**self.user) @@ -80,18 +80,18 @@ class Ticket(): created_on: dt.datetime updated_on: dt.datetime closed_on: dt.datetime - project: NamedId = None - tracker: NamedId = None - priority: NamedId = None - author: NamedId = None - status: TicketStatus = None - parent: NamedId = None + project: NamedId|None = None + tracker: NamedId|None = None + priority: NamedId|None = None + author: NamedId|None = None + status: TicketStatus|None = None + parent: NamedId|None = None spent_hours: float = 0.0 total_spent_hours: float = 0.0 - category: str = None - assigned_to: NamedId = None - custom_fields: list[CustomField] = None - journals: list[TicketNote] = None + category: str|None = None + assigned_to: NamedId|None = None + custom_fields: list[CustomField]|None = None + journals: list[TicketNote]|None = None def __post_init__(self): self.status = TicketStatus(**self.status) @@ -117,7 +117,7 @@ def __post_init__(self): if self.journals: self.journals = [TicketNote(**note) for note in self.journals] - def get_custom_field(self, name: str) -> str: + def get_custom_field(self, name: str) -> str | None: if self.custom_fields: for field in self.custom_fields: if field.name == name: @@ -127,7 +127,7 @@ def get_custom_field(self, name: str) -> str: def __str__(self): return f"#{self.id} {self.project} {self.status} {self.priority} {self.assigned_to}: {self.subject}" - def get_sync_record(self, expected_channel: int) -> synctime.SyncRecord: + def get_sync_record(self, expected_channel: int) -> synctime.SyncRecord | None: # Parse custom_field into datetime # lookup field by name token = self.get_custom_field(SYNC_FIELD_NAME) @@ -156,9 +156,10 @@ def get_sync_record(self, expected_channel: int) -> synctime.SyncRecord: # self.update_sync_record(record) same REALLY as above ^^^^ log.debug(f"created new sync record, none found: {record}") return record + return None - def get_notes(self, since:dt.datetime=None) -> list[TicketNote]: + def get_notes(self, since:dt.datetime|None=None) -> list[TicketNote]: notes = [] for note in self.journals: @@ -230,25 +231,21 @@ def create(self, user:User, subject, body, attachments=None) -> Ticket: response.headers['X-Request-Id']) - def update(self, ticket_id:str, fields:dict, user_login:str=None) -> Ticket: + def update(self, ticket_id:int, fields:dict[str,str], user_login:str|None=None) -> Ticket|None: """update a redmine ticket""" # PUT a simple JSON structure data = { - 'issue': {} + 'issue': fields } - data['issue'] = fields - - response = self.session.put(f"{ISSUE_RESOURCE}{ticket_id}.json", json.dumps(data), user_login) - if response: - # no body, so re-get the updated tickets? - return self.get(ticket_id) + self.session.put(f"{ISSUE_RESOURCE}{ticket_id}.json", json.dumps(data), user_login) + return self.get(ticket_id) def append_message(self, ticket_id:int, user_login:str, note:str, attachments=None): """append a note to a ticket""" # PUT a simple JSON structure - data = { + data:dict = { 'issue': { 'notes': note, } @@ -293,7 +290,7 @@ def get_tickets_by(self, user) -> list[Ticket]: return None - def get(self, ticket_id:int, include_journals:bool = False) -> Ticket: + def get(self, ticket_id:int, include_journals:bool = False) -> Ticket|None: """get a ticket by ID""" if ticket_id is None or ticket_id == 0: #log.debug(f"Invalid ticket number: {ticket_id}") @@ -483,7 +480,7 @@ def assign_ticket(self, ticket_id, user:User, user_id=None): self.update(ticket_id, fields, user_id) - def progress_ticket(self, ticket_id, user_id=None): # TODO notes + def progress_ticket(self, ticket_id, user_id=None): fields = { "assigned_to_id": "me", "status_id": "2", # "In Progress" @@ -491,12 +488,12 @@ def progress_ticket(self, ticket_id, user_id=None): # TODO notes self.update(ticket_id, fields, user_id) - def reject_ticket(self, ticket_id, user_id=None): # TODO notes + def reject_ticket(self, ticket_id, user_id=None) -> Ticket: fields = { "assigned_to_id": "", "status_id": "5", # "Reject" } - self.update(ticket_id, fields, user_id) + return self.update(ticket_id, fields, user_id) def unassign_ticket(self, ticket_id, user_id=None): diff --git a/users.py b/users.py index 25cdaf0..74f073f 100644 --- a/users.py +++ b/users.py @@ -118,7 +118,7 @@ def __init__(self): self.users: dict[str, int] = {} self.user_ids: dict[int, User] = {} self.user_emails: dict[str, int] = {} - self.discord_users: dict[str, int] = {} + self.discord_ids: dict[str, int] = {} self.teams: dict[str, Team] = {} @@ -127,7 +127,7 @@ def clear(self): self.users.clear() self.user_ids.clear() self.user_emails.clear() - self.discord_users.clear() + self.discord_ids.clear() def cache_user(self, user: User) -> None: @@ -138,7 +138,7 @@ def cache_user(self, user: User) -> None: self.users[user.login] = user.id self.user_emails[user.mail] = user.id if user.discord_id: - self.discord_users[user.discord_id] = user.id + self.discord_ids[user.discord_id] = user.id def cache_team(self, team: Team) -> None: @@ -163,8 +163,8 @@ def find(self, name): return self.get(self.user_emails[name]) elif name in self.users: return self.get(self.users[name]) - elif name in self.discord_users: - return self.get(self.discord_users[name]) + elif name in self.discord_ids: + return self.get(self.discord_ids[name]) elif name in self.teams: return self.teams[name] #ugly. put groups in user collection? else: @@ -181,8 +181,8 @@ def find_discord_user(self, discord_user_id:str) -> User: if discord_user_id is None: return None - if discord_user_id in self.discord_users: - user_id = self.discord_users[discord_user_id] + if discord_user_id in self.discord_ids: + user_id = self.discord_ids[discord_user_id] return self.user_ids[user_id] else: return None @@ -504,7 +504,7 @@ def reindex_users(self): self.cache.cache_user(user) # several internal indicies log.debug(f"indexed {len(all_users)} users") - log.debug(f"discord users: {self.cache.discord_users}") + log.debug(f"discord users: {self.cache.discord_ids}") else: log.warning("No users to index") @@ -533,7 +533,6 @@ def reindex(self): # load credentials from dotenv import load_dotenv load_dotenv() - users = UserManager(RedmineSession.fromenv()) for teamname in users.get_all_teams(): team = users.get_team_by_name(teamname)