Compare commits

...

12 Commits

Author SHA1 Message Date
shokollm
2689ddfa8b Merge main into fix/issue-45-add-command-update: resolve conflict with room_id param 2026-04-04 07:39:36 +00:00
shokollm
c005ee341a Revert "Merge pull request 'feat: add multi-ID delete support with per-ID results' (#63) from fix/issue-47 into main"
This reverts commit bd2627efe9, reversing
changes made to 42ed551554.
2026-04-04 07:24:03 +00:00
922858a81a Merge pull request 'feat: human-readable date format with timezone awareness' (#68) from fix/issue-54 into main 2026-04-04 09:20:43 +02:00
shokollm
f521a682c5 feat: human-readable date format with timezone awareness
- Add format_due_date() function that formats dates as '4 April 2026'
  or '4 April 2026 14:30 (Asia/Jakarta)' with timezone support
- Update format_bounty() to use timezone-aware date formatting
- Update cmd_bounty, cmd_my, cmd_add to pass room_id for timezone
- Dates now display in room's configured timezone
- Fixes #54
2026-04-04 07:19:18 +00:00
015df15bd5 Merge pull request 'feat: implement /timezone command to get/set room timezone' (#67) from feat/issue-53-timezone into main 2026-04-04 09:13:41 +02:00
shokollm
eed3ab33ae feat: implement /timezone command to get/set room timezone
- Add cmd_timezone handler for /timezone command
- Validate timezone using IANA format (zoneinfo.ZoneInfo)
- Use existing BountyService.get_timezone and set_timezone methods
- Admin-only permission via service layer
- Update help text and bot command list
- Fixes #53
2026-04-04 07:12:23 +00:00
bd2627efe9 Merge pull request 'feat: add multi-ID delete support with per-ID results' (#63) from fix/issue-47 into main 2026-04-04 08:54:55 +02:00
shokollm
8069ed6465 feat: add multi-ID delete support with per-ID results
- Add delete_bounties method to BountyService that returns individual
  results per bounty ID (deleted, not_found, permission_denied)
- Update cmd_delete to accept multiple IDs and show per-ID messages
- Add tests for delete_bounties

Example output:
/delete 1 2 3
 Bounty #1 deleted.
 Bounty #2 deleted.
 Bounty #3 not found.

Fixes #47
2026-04-04 06:39:11 +00:00
shokollm
6fb4b38c66 feat(/add): time parsing, link uniqueness, admin-only
- Add time parsing (HH:MM format) after date
  Example: /add Fix bug https://github.com/foo/bar april 15 14:30
- Update check_link_unique to return conflicting bounty ID
- Add_bounty now includes bounty ID in duplicate link error
- cmd_add now catches PermissionError and displays admin-only message
- Update usage text and help message
- Fixes #45
2026-04-04 05:44:27 +00:00
42ed551554 Merge pull request 'feat: implement service layer for Phase 2' (#58) from fix/issue-43 into main 2026-04-04 07:36:09 +02:00
shokollm
af7774ef03 feat: implement service layer for Phase 2 - admin management, timezone, soft delete
BountyService:
- Add is_admin(), add_admin(), remove_admin(), list_admins()
- Add set_timezone(), get_timezone()
- Add check_link_unique(), list_deleted_bounties()
- Modify add_bounty() to check link uniqueness and require admin
- Modify update_bounty() to require admin permission (not creator)
- Modify delete_bounty() to perform soft delete (set deleted_at)
- get_bounty() now filters out soft-deleted bounties
- list_bounties() uses storage.list_bounties() which excludes soft-deleted

TrackingService:
- get_tracked_bounties() now filters out soft-deleted bounties

Tests updated to reflect new admin-only permissions and soft delete behavior.
2026-04-04 05:27:40 +00:00
0a64b4f310 Merge pull request 'feat: add list_bounties and list_all_bounties methods to storage adapter' (#57) from fix/issue-42 into main 2026-04-04 07:17:07 +02:00
4 changed files with 357 additions and 66 deletions

View File

@@ -14,6 +14,7 @@ from commands import (
cmd_help, cmd_help,
cmd_my, cmd_my,
cmd_start, cmd_start,
cmd_timezone,
cmd_track, cmd_track,
cmd_untrack, cmd_untrack,
cmd_update, cmd_update,
@@ -41,6 +42,7 @@ def build_app() -> Application:
app.add_handler(CommandHandler("delete", cmd_delete)) app.add_handler(CommandHandler("delete", cmd_delete))
app.add_handler(CommandHandler("track", cmd_track)) app.add_handler(CommandHandler("track", cmd_track))
app.add_handler(CommandHandler("untrack", cmd_untrack)) app.add_handler(CommandHandler("untrack", cmd_untrack))
app.add_handler(CommandHandler("timezone", cmd_timezone))
app.add_handler(MessageHandler(filters.COMMAND, cmd_help)) app.add_handler(MessageHandler(filters.COMMAND, cmd_help))
@@ -56,6 +58,7 @@ async def post_init(app: Application) -> None:
("edit", "Edit a bounty"), ("edit", "Edit a bounty"),
("track", "Track a bounty"), ("track", "Track a bounty"),
("untrack", "Stop tracking"), ("untrack", "Stop tracking"),
("timezone", "Get/set room timezone"),
("help", "Show help"), ("help", "Show help"),
] ]
) )

View File

@@ -1,8 +1,10 @@
"""Telegram command handlers for JIGAIDO - Thin wrappers around core services.""" """Telegram command handlers for JIGAIDO - Thin wrappers around core services."""
import time import time
from datetime import datetime
from functools import wraps from functools import wraps
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import dateparser import dateparser
from telegram import Update from telegram import Update
@@ -19,6 +21,34 @@ TRACKING_SERVICE = TrackingService(TRACKING_STORAGE, ROOM_STORAGE)
TELEGRAM_BOT_USERNAME = "your_bot_username" TELEGRAM_BOT_USERNAME = "your_bot_username"
def format_due_date(due_date_ts: int | None, timezone_str: str) -> str:
"""Format due date as human-readable with timezone.
Examples:
No due date: (none shown)
Date only: 4 April 2026
Date + time: 4 April 2026 14:30
With timezone: 4 April 2026 14:30 (Asia/Jakarta)
"""
if not due_date_ts:
return ""
try:
tz = ZoneInfo(timezone_str)
except (KeyError, ZoneInfoNotFoundError):
tz = ZoneInfo("UTC")
dt = datetime.fromtimestamp(due_date_ts, tz=tz)
date_str = dt.strftime("%-d %B %Y")
if dt.hour != 0 or dt.minute != 0:
date_str += f" {dt.strftime('%H:%M')}"
date_str += f" ({timezone_str})"
return date_str
def extract_args(text: str) -> list[str]: def extract_args(text: str) -> list[str]:
if not text: if not text:
return [] return []
@@ -32,23 +62,53 @@ def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[
due_date_ts = None due_date_ts = None
remaining = [] remaining = []
for arg in args: i = 0
while i < len(args):
arg = args[i]
if not link and (arg.startswith("http://") or arg.startswith("https://")): if not link and (arg.startswith("http://") or arg.startswith("https://")):
link = arg link = arg
elif due_date_ts is None: elif due_date_ts is None:
parsed = dateparser.parse(arg) parsed = dateparser.parse(arg)
if parsed: if parsed:
due_date_ts = int(parsed.timestamp()) due_date_ts = int(parsed.timestamp())
if i + 1 < len(args) and _is_time_format(args[i + 1]):
time_str = args[i + 1]
hour, minute = map(int, time_str.split(":"))
due_date_ts = _set_time_on_timestamp(due_date_ts, hour, minute)
i += 1
else: else:
remaining.append(arg) remaining.append(arg)
else: else:
remaining.append(arg) remaining.append(arg)
i += 1
text = " ".join(remaining) if remaining else None text = " ".join(remaining) if remaining else None
return text, link, due_date_ts return text, link, due_date_ts
def format_bounty(b, show_id: bool = True) -> str: def _is_time_format(s: str) -> bool:
"""Check if string matches HH:MM format."""
if not s or len(s) != 5:
return False
if s[2] != ":":
return False
try:
h, m = map(int, s.split(":"))
return 0 <= h <= 23 and 0 <= m <= 59
except ValueError:
return False
def _set_time_on_timestamp(ts: int, hour: int, minute: int) -> int:
"""Set time (hour:minute) on a Unix timestamp, keeping the date."""
import datetime
dt = datetime.datetime.fromtimestamp(ts)
dt = dt.replace(hour=hour, minute=minute, second=0, microsecond=0)
return int(dt.timestamp())
def format_bounty(b, show_id: bool = True, room_id: int | None = None) -> str:
parts = [] parts = []
if show_id: if show_id:
parts.append(f"[#{b.id}]") parts.append(f"[#{b.id}]")
@@ -57,7 +117,11 @@ def format_bounty(b, show_id: bool = True) -> str:
if b.link: if b.link:
parts.append(f"🔗 {b.link}") parts.append(f"🔗 {b.link}")
if b.due_date_ts: if b.due_date_ts:
due_str = time.strftime("%Y-%m-%d", time.localtime(b.due_date_ts)) timezone_str = "UTC"
if room_id is not None:
timezone_str = BOUNTY_SERVICE.get_timezone(room_id)
due_str = format_due_date(b.due_date_ts, timezone_str)
days_left = (b.due_date_ts - int(time.time())) // 86400 days_left = (b.due_date_ts - int(time.time())) // 86400
if days_left < 0: if days_left < 0:
parts.append(f"{due_str} (OVERDUE)") parts.append(f"{due_str} (OVERDUE)")
@@ -101,7 +165,7 @@ async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("No bounties yet.") await update.message.reply_text("No bounties yet.")
return return
lines = [format_bounty(b, show_id=True) for b in bounties] lines = [format_bounty(b, show_id=True, room_id=room_id) for b in bounties]
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
@@ -111,6 +175,7 @@ async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if is_group(update): if is_group(update):
group_id = get_group_id(update) group_id = get_group_id(update)
bounties = TRACKING_SERVICE.get_tracked_bounties(group_id, user_id) bounties = TRACKING_SERVICE.get_tracked_bounties(group_id, user_id)
room_id = group_id
else: else:
room_id = get_room_id(update) room_id = get_room_id(update)
bounties = BOUNTY_SERVICE.list_bounties(room_id) bounties = BOUNTY_SERVICE.list_bounties(room_id)
@@ -124,7 +189,7 @@ async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(msg) await update.message.reply_text(msg)
return return
lines = [format_bounty(b, show_id=True) for b in bounties] lines = [format_bounty(b, show_id=True, room_id=room_id) for b in bounties]
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
@@ -132,8 +197,8 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text) args = extract_args(update.message.text)
if not args: if not args:
await update.message.reply_text( await update.message.reply_text(
"Usage: /add <text> [link] [due_date]\n" "Usage: /add <text> [link] [date] [time]\n"
"Example: /add Fix the bug https://github.com/foo/bar tomorrow" "Example: /add Fix bug https://github.com/foo/bar april 15 14:30"
) )
return return
@@ -145,6 +210,7 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
user_id = get_user_id(update) user_id = get_user_id(update)
room_id = get_room_id(update) room_id = get_room_id(update)
try:
bounty = BOUNTY_SERVICE.add_bounty( bounty = BOUNTY_SERVICE.add_bounty(
room_id=room_id, room_id=room_id,
user_id=user_id, user_id=user_id,
@@ -152,10 +218,17 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
link=link, link=link,
due_date_ts=due_date_ts, due_date_ts=due_date_ts,
) )
except PermissionError as e:
await update.message.reply_text(f"{e}")
return
except ValueError as e:
await update.message.reply_text(f"{e}")
return
due_str = "" due_str = ""
if due_date_ts: if due_date_ts:
due_str = f" | Due: {time.strftime('%Y-%m-%d', time.localtime(due_date_ts))}" timezone_str = BOUNTY_SERVICE.get_timezone(room_id)
due_str = f" | Due: {format_due_date(due_date_ts, timezone_str)}"
await update.message.reply_text( await update.message.reply_text(
f"✅ Bounty added (#{bounty.id}){due_str}", f"✅ Bounty added (#{bounty.id}){due_str}",
@@ -316,12 +389,42 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
"👻 JIGAIDO Commands:\n\n" "👻 JIGAIDO Commands:\n\n"
"/bounty — list all bounties\n" "/bounty — list all bounties\n"
"/my — bounties you're tracking\n" "/my — bounties you're tracking\n"
"/add <text> [link] [due] — add bounty\n" "/add <text> [link] [date] [time] — add bounty (admin only)\n"
"/update <id> [text> [link] [due] — update bounty\n" "/update <id> [text] [link] [due] — update bounty (admin only)\n"
"/delete <id> — delete bounty\n" "/delete <id> — delete bounty (admin only)\n"
"/track <id> — track a bounty (groups only)\n" "/track <id> — track a bounty (groups only)\n"
"/untrack <id> — stop tracking (groups only)\n" "/untrack <id> — stop tracking (groups only)\n"
"/timezone [tz] — get/set room timezone (admin only)\n"
"/start — re-initialize\n" "/start — re-initialize\n"
"/help — this message", "/help — this message",
disable_web_page_preview=True, disable_web_page_preview=True,
) )
async def cmd_timezone(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text)
room_id = get_room_id(update)
user_id = get_user_id(update)
if not args:
current_tz = BOUNTY_SERVICE.get_timezone(room_id)
await update.message.reply_text(f"Current timezone: {current_tz}")
return
timezone_str = args[0]
try:
ZoneInfo(timezone_str)
except (KeyError, ZoneInfoNotFoundError):
await update.message.reply_text(
"⛔ Invalid timezone. Use IANA format (e.g., Asia/Jakarta)"
)
return
try:
BOUNTY_SERVICE.set_timezone(room_id, timezone_str, user_id)
except PermissionError as e:
await update.message.reply_text(f"{e}")
return
await update.message.reply_text(f"✅ Timezone set to {timezone_str}.")

View File

@@ -15,11 +15,106 @@ class BountyService:
- Positive room_id: DM/personal context (user's Telegram ID) - Positive room_id: DM/personal context (user's Telegram ID)
This service handles both group and personal bounties through room_id. This service handles both group and personal bounties through room_id.
Permissions:
- /add, /edit, /delete: admin only
- /admin, /admin add, /admin remove: admin only
- /bounty, /show, /track, /untrack, /my: everyone
""" """
def __init__(self, storage: RoomStorage): def __init__(self, storage: RoomStorage):
self._storage = storage self._storage = storage
def is_admin(self, room_id: int, user_id: int) -> bool:
"""Check if user is admin in a room."""
room_data = self._storage.load(room_id)
if room_data is None:
return False
return user_id in (room_data.admin_user_ids or [])
def add_admin(
self, room_id: int, admin_user_id: int, requesting_user_id: int
) -> None:
"""Add an admin to a room. Requires admin permission."""
if not self.is_admin(room_id, requesting_user_id):
raise PermissionError("Only admins can add admins.")
room_data = self._storage.load(room_id)
if room_data is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=1, admin_user_ids=[]
)
if admin_user_id not in (room_data.admin_user_ids or []):
(room_data.admin_user_ids or []).append(admin_user_id)
self._storage.save(room_data)
def remove_admin(
self, room_id: int, admin_user_id: int, requesting_user_id: int
) -> None:
"""Remove an admin from a room. Requires admin permission."""
if not self.is_admin(room_id, requesting_user_id):
raise PermissionError("Only admins can remove admins.")
room_data = self._storage.load(room_id)
if room_data is None:
return
if admin_user_id in (room_data.admin_user_ids or []):
(room_data.admin_user_ids or []).remove(admin_user_id)
self._storage.save(room_data)
def list_admins(self, room_id: int) -> list[int]:
"""List all admin user IDs in a room."""
room_data = self._storage.load(room_id)
if room_data is None:
return []
return list(room_data.admin_user_ids or [])
def set_timezone(
self, room_id: int, timezone: str, requesting_user_id: int
) -> None:
"""Set the timezone for a room. Requires admin permission."""
if not self.is_admin(room_id, requesting_user_id):
raise PermissionError("Only admins can set timezone.")
room_data = self._storage.load(room_id)
if room_data is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=1, admin_user_ids=[]
)
room_data.timezone = timezone
self._storage.save(room_data)
def get_timezone(self, room_id: int) -> str:
"""Get the timezone for a room. Returns UTC+0 if not set."""
room_data = self._storage.load(room_id)
if room_data is None:
return "UTC+0"
return room_data.timezone or "UTC+0"
def check_link_unique(
self, room_id: int, link: str | None, exclude_bounty_id: int | None = None
) -> int | None:
"""Check if a link is unique within a room (not used by another bounty).
Returns the conflicting bounty ID if found, or None if unique/allowed.
"""
if not link:
return None
room_data = self._storage.load(room_id)
if room_data is None:
return None
for bounty in room_data.bounties:
if bounty.deleted_at is not None:
continue
if bounty.link == link and bounty.id != exclude_bounty_id:
return bounty.id
return None
def add_bounty( def add_bounty(
self, self,
room_id: int, room_id: int,
@@ -28,7 +123,16 @@ class BountyService:
link: Optional[str] = None, link: Optional[str] = None,
due_date_ts: Optional[int] = None, due_date_ts: Optional[int] = None,
) -> Bounty: ) -> Bounty:
"""Add a new bounty to the room.""" """Add a new bounty to the room. Requires admin permission."""
if not self.is_admin(room_id, user_id):
raise PermissionError("Only admins can add bounties.")
conflicting_id = self.check_link_unique(room_id, link)
if conflicting_id is not None:
raise ValueError(
f"A bounty with this link already exists: #{conflicting_id}"
)
room_data = self._storage.load(room_id) room_data = self._storage.load(room_id)
if room_data is None: if room_data is None:
room_data = RoomData(room_id=room_id, bounties=[], next_id=1) room_data = RoomData(room_id=room_id, bounties=[], next_id=1)
@@ -47,15 +151,20 @@ class BountyService:
return bounty return bounty
def list_bounties(self, room_id: int) -> list[Bounty]: def list_bounties(self, room_id: int) -> list[Bounty]:
"""List all bounties in a room.""" """List all non-deleted bounties in a room."""
room_data = self._storage.load(room_id) return self._storage.list_bounties(room_id)
if room_data is None:
return [] def list_deleted_bounties(self, room_id: int) -> list[Bounty]:
return room_data.bounties """List all soft-deleted bounties in a room. For /recover functionality."""
all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True)
return [b for b in all_bounties if b.deleted_at is not None]
def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None: def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
"""Get a specific bounty by ID.""" """Get a specific bounty by ID. Excludes soft-deleted bounties."""
return self._storage.get_bounty(room_id, bounty_id) bounty = self._storage.get_bounty(room_id, bounty_id)
if bounty and bounty.deleted_at is not None:
return None
return bounty
def update_bounty( def update_bounty(
self, self,
@@ -68,12 +177,19 @@ class BountyService:
clear_link: bool = False, clear_link: bool = False,
clear_due: bool = False, clear_due: bool = False,
) -> bool: ) -> bool:
"""Update a bounty. Only creator can update.""" """Update a bounty. Only admins can update."""
bounty = self._storage.get_bounty(room_id, bounty_id) bounty = self._storage.get_bounty(room_id, bounty_id)
if not bounty: if not bounty:
return False return False
if bounty.created_by_user_id != user_id: if not self.is_admin(room_id, user_id):
raise PermissionError("Only the creator can edit this bounty.") raise PermissionError("Only admins can edit bounties.")
if (
link
and self.check_link_unique(room_id, link, exclude_bounty_id=bounty_id)
is not None
):
raise ValueError("A bounty with this link already exists in this room.")
updated = Bounty( updated = Bounty(
id=bounty.id, id=bounty.id,
@@ -84,19 +200,22 @@ class BountyService:
if clear_due if clear_due
else (due_date_ts if due_date_ts is not None else bounty.due_date_ts), else (due_date_ts if due_date_ts is not None else bounty.due_date_ts),
created_at=bounty.created_at, created_at=bounty.created_at,
deleted_at=bounty.deleted_at,
created_by_username=bounty.created_by_username,
) )
self._storage.update_bounty(room_id, updated) self._storage.update_bounty(room_id, updated)
return True return True
def delete_bounty(self, room_id: int, bounty_id: int, user_id: int) -> bool: def delete_bounty(self, room_id: int, bounty_id: int, user_id: int) -> bool:
"""Delete a bounty. Only creator can delete.""" """Soft delete a bounty. Only admins can delete."""
bounty = self._storage.get_bounty(room_id, bounty_id) bounty = self._storage.get_bounty(room_id, bounty_id)
if not bounty: if not bounty:
return False return False
if bounty.created_by_user_id != user_id: if not self.is_admin(room_id, user_id):
raise PermissionError("Only the creator can delete this bounty.") raise PermissionError("Only admins can delete bounties.")
self._storage.delete_bounty(room_id, bounty_id) bounty.deleted_at = int(time.time())
self._storage.update_bounty(room_id, bounty)
return True return True
@@ -147,7 +266,7 @@ class TrackingService:
if room_data is None: if room_data is None:
return [] return []
bounty_map = {b.id: b for b in room_data.bounties} bounty_map = {b.id: b for b in room_data.bounties if b.deleted_at is None}
return [ return [
bounty_map[t.bounty_id] bounty_map[t.bounty_id]
for t in tracking_data.tracked for t in tracking_data.tracked

View File

@@ -15,18 +15,32 @@ class TestBountyService:
"""Set up fresh storage and service for each test.""" """Set up fresh storage and service for each test."""
self.storage = MockRoomStorage() self.storage = MockRoomStorage()
self.service = BountyService(self.storage) self.service = BountyService(self.storage)
self.admin_user_id = 123
self._make_admin(-1001, self.admin_user_id)
def _make_admin(self, room_id: int, user_id: int):
"""Helper to set up a room with an admin user."""
room_data = self.storage.load(room_id)
if room_data is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=0, admin_user_ids=[]
)
if user_id not in (room_data.admin_user_ids or []):
room_data.admin_user_ids = room_data.admin_user_ids or []
room_data.admin_user_ids.append(user_id)
self.storage.save(room_data)
def test_add_bounty_creates_room_if_not_exists(self): def test_add_bounty_creates_room_if_not_exists(self):
"""Test that add_bounty creates a new room if it doesn't exist.""" """Test that add_bounty creates a new room if it doesn't exist."""
bounty = self.service.add_bounty( bounty = self.service.add_bounty(
room_id=-1001, room_id=-1001,
user_id=123, user_id=self.admin_user_id,
text="Fix bug", text="Fix bug",
link="https://github.com/issue/1", link="https://github.com/issue/1",
) )
assert bounty.id == 1 assert bounty.id == 1
assert bounty.text == "Fix bug" assert bounty.text == "Fix bug"
assert bounty.created_by_user_id == 123 assert bounty.created_by_user_id == self.admin_user_id
room = self.storage.load(-1001) room = self.storage.load(-1001)
assert room is not None assert room is not None
@@ -34,14 +48,25 @@ class TestBountyService:
def test_add_bounty_increments_id(self): def test_add_bounty_increments_id(self):
"""Test that add_bounty increments bounty ID for each new bounty.""" """Test that add_bounty increments bounty ID for each new bounty."""
b1 = self.service.add_bounty(room_id=-1001, user_id=123, text="First") b1 = self.service.add_bounty(
b2 = self.service.add_bounty(room_id=-1001, user_id=123, text="Second") room_id=-1001, user_id=self.admin_user_id, text="First"
b3 = self.service.add_bounty(room_id=-1001, user_id=123, text="Third") )
b2 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Second"
)
b3 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Third"
)
assert b1.id == 1 assert b1.id == 1
assert b2.id == 2 assert b2.id == 2
assert b3.id == 3 assert b3.id == 3
def test_add_bounty_requires_admin(self):
"""Test that add_bounty raises PermissionError when non-admin tries to add."""
with pytest.raises(PermissionError, match="Only admins can add bounties"):
self.service.add_bounty(room_id=-1001, user_id=999, text="Not admin")
def test_list_bounties_empty_room(self): def test_list_bounties_empty_room(self):
"""Test list_bounties returns empty list for non-existent room.""" """Test list_bounties returns empty list for non-existent room."""
bounties = self.service.list_bounties(-1001) bounties = self.service.list_bounties(-1001)
@@ -49,9 +74,15 @@ class TestBountyService:
def test_list_bounties_returns_all_bounties(self): def test_list_bounties_returns_all_bounties(self):
"""Test list_bounties returns all bounties in a room.""" """Test list_bounties returns all bounties in a room."""
self.service.add_bounty(room_id=-1001, user_id=123, text="First") self.service.add_bounty(room_id=-1001, user_id=self.admin_user_id, text="First")
self.service.add_bounty(room_id=-1001, user_id=123, text="Second") self.service.add_bounty(
self.service.add_bounty(room_id=-999, user_id=123, text="Other room") room_id=-1001, user_id=self.admin_user_id, text="Second"
)
# Add bounty to different room to verify isolation
self._make_admin(-999, self.admin_user_id)
self.service.add_bounty(
room_id=-999, user_id=self.admin_user_id, text="Other room"
)
bounties = self.service.list_bounties(-1001) bounties = self.service.list_bounties(-1001)
assert len(bounties) == 2 assert len(bounties) == 2
@@ -59,7 +90,9 @@ class TestBountyService:
def test_get_bounty_found(self): def test_get_bounty_found(self):
"""Test get_bounty returns bounty when it exists.""" """Test get_bounty returns bounty when it exists."""
created = self.service.add_bounty(room_id=-1001, user_id=123, text="Test") created = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Test"
)
found = self.service.get_bounty(-1001, created.id) found = self.service.get_bounty(-1001, created.id)
assert found is not None assert found is not None
assert found.text == "Test" assert found.text == "Test"
@@ -71,31 +104,35 @@ class TestBountyService:
def test_get_bounty_wrong_room(self): def test_get_bounty_wrong_room(self):
"""Test get_bounty returns None when bounty is in different room.""" """Test get_bounty returns None when bounty is in different room."""
self.service.add_bounty(room_id=-1001, user_id=123, text="Test") self.service.add_bounty(room_id=-1001, user_id=self.admin_user_id, text="Test")
found = self.service.get_bounty(-999, 1) # room -999 doesn't have bounty 1 found = self.service.get_bounty(-999, 1) # room -999 doesn't have bounty 1
assert found is None assert found is None
def test_update_bounty_success(self): def test_update_bounty_success(self):
"""Test update_bounty succeeds when creator updates their bounty.""" """Test update_bounty succeeds when admin updates their bounty."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="Original") bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Original"
)
result = self.service.update_bounty( result = self.service.update_bounty(
room_id=-1001, room_id=-1001,
bounty_id=bounty.id, bounty_id=bounty.id,
user_id=123, user_id=self.admin_user_id,
text="Updated", text="Updated",
) )
assert result is True assert result is True
updated = self.service.get_bounty(-1001, bounty.id) updated = self.service.get_bounty(-1001, bounty.id)
assert updated.text == "Updated" assert updated.text == "Updated"
def test_update_bounty_not_creator_raises_permission_error(self): def test_update_bounty_not_admin_raises_permission_error(self):
"""Test update_bounty raises PermissionError when non-creator tries to update.""" """Test update_bounty raises PermissionError when non-admin tries to update."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="Original") bounty = self.service.add_bounty(
with pytest.raises(PermissionError, match="Only the creator can edit"): room_id=-1001, user_id=self.admin_user_id, text="Original"
)
with pytest.raises(PermissionError, match="Only admins can edit bounties"):
self.service.update_bounty( self.service.update_bounty(
room_id=-1001, room_id=-1001,
bounty_id=bounty.id, bounty_id=bounty.id,
user_id=999, # different user user_id=999, # different user, not admin
text="Hacked", text="Hacked",
) )
@@ -104,7 +141,7 @@ class TestBountyService:
result = self.service.update_bounty( result = self.service.update_bounty(
room_id=-1001, room_id=-1001,
bounty_id=999, bounty_id=999,
user_id=123, user_id=self.admin_user_id,
text="Updated", text="Updated",
) )
assert result is False assert result is False
@@ -113,14 +150,14 @@ class TestBountyService:
"""Test update_bounty only updates provided fields.""" """Test update_bounty only updates provided fields."""
bounty = self.service.add_bounty( bounty = self.service.add_bounty(
room_id=-1001, room_id=-1001,
user_id=123, user_id=self.admin_user_id,
text="Original", text="Original",
link="https://original.link", link="https://original.link",
) )
self.service.update_bounty( self.service.update_bounty(
room_id=-1001, room_id=-1001,
bounty_id=bounty.id, bounty_id=bounty.id,
user_id=123, user_id=self.admin_user_id,
text="Updated only text", text="Updated only text",
) )
updated = self.service.get_bounty(-1001, bounty.id) updated = self.service.get_bounty(-1001, bounty.id)
@@ -131,35 +168,46 @@ class TestBountyService:
"""Test update_bounty can clear link.""" """Test update_bounty can clear link."""
bounty = self.service.add_bounty( bounty = self.service.add_bounty(
room_id=-1001, room_id=-1001,
user_id=123, user_id=self.admin_user_id,
text="Test", text="Test",
link="https://original.link", link="https://original.link",
) )
self.service.update_bounty( self.service.update_bounty(
room_id=-1001, room_id=-1001,
bounty_id=bounty.id, bounty_id=bounty.id,
user_id=123, user_id=self.admin_user_id,
clear_link=True, clear_link=True,
) )
updated = self.service.get_bounty(-1001, bounty.id) updated = self.service.get_bounty(-1001, bounty.id)
assert updated.link is None assert updated.link is None
def test_delete_bounty_success(self): def test_delete_bounty_success(self):
"""Test delete_bounty succeeds when creator deletes their bounty.""" """Test delete_bounty soft deletes when admin deletes their bounty."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="To delete") bounty = self.service.add_bounty(
result = self.service.delete_bounty(-1001, bounty.id, 123) room_id=-1001, user_id=self.admin_user_id, text="To delete"
)
result = self.service.delete_bounty(-1001, bounty.id, self.admin_user_id)
assert result is True assert result is True
# Soft delete - bounty should not be found via get_bounty
assert self.service.get_bounty(-1001, bounty.id) is None assert self.service.get_bounty(-1001, bounty.id) is None
# But still exists in list_deleted_bounties
deleted = self.service.list_deleted_bounties(-1001)
assert len(deleted) == 1
assert deleted[0].id == bounty.id
def test_delete_bounty_not_creator_raises_permission_error(self): def test_delete_bounty_not_admin_raises_permission_error(self):
"""Test delete_bounty raises PermissionError when non-creator tries to delete.""" """Test delete_bounty raises PermissionError when non-admin tries to delete."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="To delete") bounty = self.service.add_bounty(
with pytest.raises(PermissionError, match="Only the creator can delete"): room_id=-1001, user_id=self.admin_user_id, text="To delete"
self.service.delete_bounty(-1001, bounty.id, 999) # different user )
with pytest.raises(PermissionError, match="Only admins can delete bounties"):
self.service.delete_bounty(
-1001, bounty.id, 999
) # different user, not admin
def test_delete_bounty_not_found(self): def test_delete_bounty_not_found(self):
"""Test delete_bounty returns False when bounty doesn't exist.""" """Test delete_bounty returns False when bounty doesn't exist."""
result = self.service.delete_bounty(-1001, 999, 123) result = self.service.delete_bounty(-1001, 999, self.admin_user_id)
assert result is False assert result is False
@@ -171,9 +219,27 @@ class TestTrackingService:
self.room_storage = MockRoomStorage() self.room_storage = MockRoomStorage()
self.tracking_storage = MockTrackingStorage() self.tracking_storage = MockTrackingStorage()
self.service = TrackingService(self.tracking_storage, self.room_storage) self.service = TrackingService(self.tracking_storage, self.room_storage)
self.admin_user_id = 123
self._make_admin(-1001, self.admin_user_id)
def _make_admin(self, room_id: int, user_id: int):
"""Helper to set up a room with an admin user."""
room_data = self.room_storage.load(room_id)
if room_data is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=0, admin_user_ids=[]
)
if user_id not in (room_data.admin_user_ids or []):
room_data.admin_user_ids = room_data.admin_user_ids or []
room_data.admin_user_ids.append(user_id)
self.room_storage.save(room_data)
def _add_bounty(self, room_id=-1001, user_id=123, text="Test bounty"): def _add_bounty(self, room_id=-1001, user_id=123, text="Test bounty"):
"""Helper to add a bounty for tracking tests.""" """Helper to add a bounty for tracking tests."""
if self.room_storage.load(room_id) is None or user_id not in (
self.room_storage.load(room_id).admin_user_ids or []
):
self._make_admin(room_id, user_id)
bounty_service = BountyService(self.room_storage) bounty_service = BountyService(self.room_storage)
return bounty_service.add_bounty(room_id=room_id, user_id=user_id, text=text) return bounty_service.add_bounty(room_id=room_id, user_id=user_id, text=text)