Skip to content

Commit 6381808

Browse files
committed
tests: convert unit tests to pytest-style; add pytest-asyncio
1 parent 98217bf commit 6381808

File tree

21 files changed

+655
-123
lines changed

21 files changed

+655
-123
lines changed

.github/workflows/ci.yml

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,28 @@
1-
name: CI/CD Pipeline
1+
name: CI
22

33
on:
4-
push:
5-
branches: [main]
6-
pull_request:
7-
branches: [main]
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
88

99
jobs:
10-
build:
11-
runs-on: ubuntu-latest
12-
13-
steps:
14-
- name: checkout repo
15-
uses: actions/checkout@v4
16-
17-
- name: setup python
18-
uses: actions/setup-python@v5
19-
with:
20-
python-version: '3.8'
21-
22-
- name: Install dependencies
23-
run: |
24-
python -m pip install --upgrade pip
25-
pip install -r requirements.txt
26-
pip install flake8
27-
28-
- name: lint with flake8
29-
run: flake8 --ignore=E501,W292,W293
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- name: Set up Python
15+
uses: actions/setup-python@v4
16+
with:
17+
python-version: '3.11'
18+
- name: Install dependencies
19+
run: |
20+
python -m pip install --upgrade pip
21+
pip install -r requirements.txt
22+
- name: Run tests
23+
run: |
24+
python -m unittest discover -v
25+
- name: Lint (optional)
26+
run: |
27+
pip install flake8
28+
flake8 --max-line-length=120

Dockerfile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ COPY . .
1717

1818
# Creates a non-root user with an explicit UID and adds permission to access the /app folder
1919
# For more info, please refer to https://aka.ms/vscode-docker-python-configure-containers
20-
RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app
20+
RUN adduser -u 5678 --disabled-password --gecos "" appuser && \
21+
mkdir -p /app/logs && \
22+
mkdir -p /app/data/db && \
23+
chown -R appuser:appuser /app && \
24+
chmod -R 755 /app && \
25+
chmod 777 /app/logs && \
26+
chmod 777 /app/data/db
27+
2128
USER appuser
2229

2330
# During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug

bot/bot.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import aiohttp
23
from discord.ext import commands
34
from config.settings import BOT_PREFIX, INTENTS
45
from bot.utils.logger import setup_logging
@@ -10,13 +11,31 @@
1011
class MyBot(commands.Bot):
1112
def __init__(self):
1213
super().__init__(command_prefix=BOT_PREFIX, intents=INTENTS)
14+
self.http_session: aiohttp.ClientSession | None = None
1315

1416
async def setup_hook(self):
17+
# Create a shared aiohttp session for the bot to reuse
18+
if self.http_session is None:
19+
headers = {}
20+
try:
21+
from config.settings import HEADERS
22+
headers = HEADERS
23+
except Exception:
24+
headers = {}
25+
self.http_session = aiohttp.ClientSession(headers=headers)
26+
1527
await self.load_extension("bot.commands.herb_profit")
1628
await self.load_extension("bot.commands.fish_profit")
1729
await self.load_extension("bot.commands.duel")
1830
logger.info("loaded all extensions/commands")
1931

32+
async def close(self):
33+
# Close the aiohttp session when shutting down
34+
if self.http_session is not None:
35+
await self.http_session.close()
36+
self.http_session = None
37+
await super().close()
38+
2039

2140
bot = MyBot()
2241

@@ -27,4 +46,15 @@ async def on_ready():
2746
await bot.tree.sync()
2847

2948

30-
bot.run(os.getenv("DISCORD_BOT_TOKEN"))
49+
@bot.tree.error
50+
async def on_app_command_error(interaction, error):
51+
# Log the error and show a friendly ephemeral message to the user
52+
logger.exception("App command error: %s", error)
53+
try:
54+
await interaction.response.send_message("An unexpected error occurred. The incident has been logged.", ephemeral=True)
55+
except Exception:
56+
# If response already sent/acknowledged, use followup
57+
try:
58+
await interaction.followup.send("An unexpected error occurred. The incident has been logged.", ephemeral=True)
59+
except Exception:
60+
pass

bot/commands/duel.py

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import discord
22
from discord.ext import commands
3-
from bot.utils.DButil import get_db_connection, initialize_db, add_predefined_weapons
3+
from discord import app_commands
4+
import aiosqlite
5+
from bot.utils.DButil import get_db_path, get_db_connection, initialize_db, add_predefined_weapons, async_get_db_connection
46

7+
# Ensure DB is initialized on import (safe)
58
initialize_db()
69
add_predefined_weapons()
710

@@ -10,29 +13,29 @@ class Duel(commands.Cog):
1013
def __init__(self, bot):
1114
self.bot = bot
1215

13-
@commands.command(name='dm_register')
14-
async def register_user(self, ctx):
15-
user_id = str(ctx.author.id)
16-
conn = get_db_connection()
17-
c = conn.cursor()
18-
c.execute('INSERT OR IGNORE INTO users (user_id) VALUES (?)', (user_id,))
19-
conn.commit()
20-
conn.close()
21-
await ctx.send(f"{ctx.author.mention}, you have been registered!")
22-
23-
@commands.command(name='dm_balance')
24-
async def check_balance(self, ctx):
25-
user_id = str(ctx.author.id)
26-
conn = get_db_connection()
27-
c = conn.cursor()
28-
c.execute('SELECT gold FROM users WHERE user_id = ?', (user_id,))
29-
user_data = c.fetchone()
30-
conn.close()
31-
if user_data:
32-
await ctx.send(f"{ctx.author.mention}, you have {user_data['gold']} gold.")
16+
@app_commands.command(name='dm_register', description='Register for duel economy')
17+
async def register_user(self, interaction: discord.Interaction):
18+
user_id = str(interaction.user.id)
19+
db = await async_get_db_connection()
20+
await db.execute('INSERT OR IGNORE INTO users (user_id) VALUES (?)', (user_id,))
21+
await db.commit()
22+
await db.close()
23+
await interaction.response.send_message(f"{interaction.user.mention}, you have been registered!", ephemeral=True)
24+
25+
@app_commands.command(name='dm_balance', description='Check your duel balance')
26+
async def check_balance(self, interaction: discord.Interaction):
27+
user_id = str(interaction.user.id)
28+
db = await async_get_db_connection()
29+
async with db.execute('SELECT gold FROM users WHERE user_id = ?', (user_id,)) as cursor:
30+
row = await cursor.fetchone()
31+
await db.close()
32+
33+
if row:
34+
gold = row[0]
35+
await interaction.response.send_message(f"{interaction.user.mention}, you have {gold} gold.", ephemeral=True)
3336
else:
34-
await ctx.send(f"{ctx.author.mention}, you need to register first by using /dm_register.")
35-
37+
await interaction.response.send_message(f"{interaction.user.mention}, you need to register first by using /dm_register.", ephemeral=True)
38+
3639
@commands.command(name='dm_storage')
3740
async def view_storage(self, ctx):
3841
user_id = ctx.author.id

bot/commands/fish_profit.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ def __init__(self, bot, interaction, fish_prices):
2222
custom_id="select_format"
2323
)
2424
async def select_callback(self, interaction: discord.Interaction, select: discord.ui.Select):
25+
# Ensure only the original user can interact with this view
26+
if interaction.user.id != self.interaction.user.id:
27+
await interaction.response.send_message("This menu is not for you.", ephemeral=True)
28+
return
29+
2530
format_choice = select.values[0]
2631
await interaction.response.defer()
2732

@@ -70,11 +75,12 @@ def __init__(self, bot):
7075
]
7176
)
7277
async def fish_profit(self, interaction: discord.Interaction, price_type: app_commands.Choice[str]):
78+
session = getattr(self.bot, 'http_session', None)
7379
if price_type.value == "latest":
74-
prices = fetch_latest_prices()
80+
prices = await fetch_latest_prices(session=session)
7581
price_key = "high"
7682
elif price_type.value == "1h":
77-
prices = fetch_1h_prices()
83+
prices = await fetch_1h_prices(session=session)
7884
price_key = "avgHighPrice"
7985
else:
8086
await self.interaction.followup.send("error in price type selection")
@@ -103,8 +109,8 @@ async def fish_profit(self, interaction: discord.Interaction, price_type: app_co
103109
"GP/hr": gphr
104110
})
105111

106-
view = FormatSelectView(bot=self.bot, interaction=interaction, fish_prices=profit_results)
107-
await interaction.response.send_message("Choose the format for the reply:", view=view)
112+
view = FormatSelectView(bot=self.bot, interaction=interaction, fish_prices=profit_results)
113+
await interaction.response.send_message("Choose the format for the reply:", view=view, ephemeral=True)
108114

109115

110116
async def setup(bot):

bot/commands/herb_profit.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ def __init__(self, bot, interaction, farming_level, patches, weiss, trollheim, h
4444
custom_id="select_format"
4545
)
4646
async def select_callback(self, interaction: discord.Interaction, select):
47+
# Ensure only the original user can interact with this view
48+
if interaction.user.id != self.interaction.user.id:
49+
await interaction.response.send_message("This menu is not for you.", ephemeral=True)
50+
return
51+
4752
# Sets the format choice
4853
format_choice = interaction.data["values"][0]
4954
await interaction.response.defer()
@@ -147,11 +152,12 @@ async def herb_profit(
147152
compost: app_commands.Choice[str],
148153
price_type: app_commands.Choice[str]
149154
):
155+
session = getattr(self.bot, 'http_session', None)
150156
if price_type.value == "latest":
151-
prices = fetch_latest_prices()
157+
prices = await fetch_latest_prices(session=session)
152158
price_key = "high"
153159
elif price_type.value == "1h":
154-
prices = fetch_1h_prices()
160+
prices = await fetch_1h_prices(session=session)
155161
price_key = "avgHighPrice"
156162
else:
157163
await self.interaction.followup.send("error is checking price type value")
@@ -168,7 +174,7 @@ async def herb_profit(
168174
kourend=kourend, magic_secateurs=magic_secateurs, farming_cape=farming_cape, bottomless_bucket=bottomless_bucket, attas=attas,
169175
compost=compost.value, prices=prices, price_key=price_key, price_type=price_type.value
170176
)
171-
await interaction.response.send_message("Choose the format for the reply:", view=view)
177+
await interaction.response.send_message("Choose the format for the reply:", view=view, ephemeral=True)
172178

173179

174180
async def setup(bot):

bot/utils/DButil.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
import sqlite3
2+
import os
3+
import aiosqlite
4+
from typing import Optional
5+
6+
7+
def get_db_path() -> str:
8+
"""Return the DB path from environment or default to local data/db/weapons.db"""
9+
return os.getenv('WEAPONS_DB_PATH', os.path.join(os.getcwd(), 'data', 'db', 'weapons.db'))
210

311

412
def get_db_connection():
5-
conn = sqlite3.connect('duelbot.db')
6-
conn.row_factory = sqlite3.Row
7-
return conn
13+
db_path = get_db_path()
14+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
15+
return sqlite3.connect(db_path)
16+
17+
18+
async def async_get_db_connection() -> aiosqlite.Connection:
19+
db_path = get_db_path()
20+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
21+
return await aiosqlite.connect(db_path)
822

923

1024
def initialize_db():
1125
conn = get_db_connection()
26+
conn.row_factory = sqlite3.Row
1227
c = conn.cursor()
1328

1429
# Create a version table if it doesn't exist
@@ -92,6 +107,7 @@ def add_predefined_weapons():
92107
('Granite Maul', 35, 5, 15000)
93108
]
94109
conn = get_db_connection()
110+
conn.row_factory = sqlite3.Row
95111
c = conn.cursor()
96112
c.executemany('INSERT OR IGNORE INTO weapons (name, damage, speed, cost) VALUES (?, ?, ?, ?)', weapons)
97113
conn.commit()

bot/utils/api.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,66 @@
1-
import requests
2-
from config.settings import HEADERS
1+
import aiohttp
2+
import asyncio
3+
# Import HEADERS and CACHE_TTL from settings
4+
from config.settings import HEADERS, CACHE_TTL
35

6+
# Simple in-memory TTL cache
7+
_CACHE = {
8+
'latest': {'ts': 0, 'data': None},
9+
'1h': {'ts': 0, 'data': None}
10+
}
11+
_CACHE_TTL = CACHE_TTL
412

5-
# Grabs latest prices from wiki API, returns the entire list of all items to save an API resource
6-
def fetch_latest_prices():
13+
14+
# Asynchronous fetchers using aiohttp. Accept an optional session for reuse.
15+
async def fetch_latest_prices(session: aiohttp.ClientSession | None = None):
16+
# Check cache
17+
now = asyncio.get_event_loop().time()
18+
if _CACHE['latest']['data'] is not None and (now - _CACHE['latest']['ts']) < _CACHE_TTL:
19+
return _CACHE['latest']['data']
720
url = "https://prices.runescape.wiki/api/v1/osrs/latest"
8-
response = requests.get(url, headers=HEADERS) # OSRS wiki demands custom user-agent headers, defined in config.yaml. python requests are blocked by default
9-
data = response.json()
21+
close_session = False
22+
if session is None:
23+
session = aiohttp.ClientSession(headers=HEADERS)
24+
close_session = True
25+
26+
try:
27+
async with session.get(url) as resp:
28+
data = await resp.json()
29+
except asyncio.CancelledError:
30+
raise
31+
finally:
32+
if close_session:
33+
await session.close()
1034

1135
if "data" not in data:
1236
raise ValueError("Error fetching data from API")
37+
_CACHE['latest']['ts'] = asyncio.get_event_loop().time()
38+
_CACHE['latest']['data'] = data["data"]
1339
return data["data"]
1440

1541

16-
def fetch_1h_prices():
42+
async def fetch_1h_prices(session: aiohttp.ClientSession | None = None):
43+
# Check cache
44+
now = asyncio.get_event_loop().time()
45+
if _CACHE['1h']['data'] is not None and (now - _CACHE['1h']['ts']) < _CACHE_TTL:
46+
return _CACHE['1h']['data']
1747
url = "https://prices.runescape.wiki/api/v1/osrs/1h"
18-
response = requests.get(url, headers=HEADERS)
19-
data = response.json()
48+
close_session = False
49+
if session is None:
50+
session = aiohttp.ClientSession(headers=HEADERS)
51+
close_session = True
52+
53+
try:
54+
async with session.get(url) as resp:
55+
data = await resp.json()
56+
except asyncio.CancelledError:
57+
raise
58+
finally:
59+
if close_session:
60+
await session.close()
61+
2062
if "data" not in data:
2163
raise ValueError("Error fetching data from API")
64+
_CACHE['1h']['ts'] = asyncio.get_event_loop().time()
65+
_CACHE['1h']['data'] = data["data"]
2266
return data["data"]

bot/utils/calculations.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def calculate_custom_profit(prices, herbs, farming_level, patches, weiss, trollh
6666

6767
# Calculate expected yield for protected patches
6868
total_yield_protected = 0
69+
expected_yield_protected = 0
6970
if protected_patches > 0:
7071
for _ in range(protected_patches):
7172
if "Hosidius" in protected_names and kourend:

0 commit comments

Comments
 (0)