Compare commits

...

25 Commits

Author SHA1 Message Date
shokollm
a437e64ecc Revert "Merge pull request 'feat: implement /timezone command to get/set room timezone' (#67) from feat/issue-53-timezone into main"
This reverts commit 015df15bd5, reversing
changes made to bd2627efe9.
2026-04-04 07:23:57 +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
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
shokollm
ed0d31bc04 feat: add list_bounties and list_all_bounties methods to storage adapter
Add filtering methods to JsonFileRoomStorage for Phase 2 soft delete support:
- list_bounties(room_id): returns only non-deleted bounties for normal queries
- list_all_bounties(room_id, include_deleted=True): returns all bounties for /recover

Update RoomStorage protocol to include the new methods.
Update mock classes in tests to pass isinstance checks.

Fixes #42
2026-04-04 05:12:19 +00:00
d413f6ce13 Merge pull request 'feat: Model updates - add deleted_at, timezone, admin_user_ids fields' (#56) from fix/issue-41 into main 2026-04-04 07:08:28 +02:00
shokollm
fee8504813 feat: add deleted_at, created_by_username to Bounty; timezone, admin_user_ids to RoomData
Issue #41: Model updates for Phase 2 features

Bounty model:
- Add deleted_at: int | None - timestamp when deleted (soft-delete)
- Add created_by_username: str | None - username for display purposes

RoomData model:
- Add timezone: str | None - room's timezone (e.g., "Asia/Jakarta")
- Add admin_user_ids: list[int] - list of admin user IDs

Storage adapter updated to handle new fields in load/save operations.
Tests added for new fields.
2026-04-04 04:59:58 +00:00
411e19e5d7 Merge pull request 'test: ensure tests package is importable' (#36) from fix/issue-15 into main 2026-04-03 21:10:27 +02:00
6dc3307e23 Merge pull request 'fix #16: cleanup - remove old/dead code and update docs' (#35) from fix/issue-16-cleanup into main 2026-04-03 21:09:59 +02:00
shokollm
1c55fe26b9 test: ensure tests package is importable
Add tests/__init__.py to make tests/ a proper Python package,
enabling cross-imports between test modules (e.g., test_services
importing from test_ports).

All 145 tests pass:
- tests/: 90 tests
- apps/telegram-bot/tests/: 55 tests
2026-04-03 15:12:59 +00:00
shokollm
99a80b0c62 fix #16: cleanup - remove old/dead code and update docs
- Delete apps/telegram-bot/storage.py (replaced by adapters/storage/json_file.py)
- Delete apps/telegram-bot/__init__.py (empty file)
- Delete apps/telegram-bot/requirements-dev.txt (dev deps in pyproject.toml)
- Update SPEC.md with new hexagonal architecture (core, adapters, apps)
- Update SPEC.md command reference: /update -> /edit
- Update README.md with new project structure and quick start
- Update CONTRIBUTING.md with new architecture and dev setup
2026-04-03 15:12:31 +00:00
50b09ef721 Merge pull request 'refactor(telegram-bot): add /edit command and make bot.py minimal entrypoint' (#34) from fix/issue-14 into main 2026-04-03 16:36:27 +02:00
shokollm
67d801d9de refactor(telegram-bot): add /edit command and make bot.py minimal entrypoint
- Add cmd_edit as alias for cmd_update
- Update bot.py to import commands directly instead of via module
- Register /edit command in bot and post_init commands list
- Clean up unused imports in bot.py

Fixes #14
2026-04-03 14:27:01 +00:00
f5cb28d45c Merge pull request 'refactor(commands): use core services instead of storage module' (#33) from fix/issue-13 into main 2026-04-03 16:14:24 +02:00
44bd0488c4 Merge pull request 'feat(cli): implement CLI for jigaido bounty tracker' (#32) from fix/issue-11 into main 2026-04-03 16:14:07 +02:00
shokollm
5e6a5f16b1 Add CLI unit tests for jigaido issue #11
- Tests mock dependencies and verify command existence, flag processing, and output format
- Tests use --group-id=-1001 format (equals sign) to avoid argparse treating -1001 as a flag
- All 17 tests passing
2026-04-03 13:18:48 +00:00
shokollm
0c36aa7b88 test(commands): add unit tests for command handlers
Add comprehensive unit tests for all command handlers:
- TestHelperFunctions: is_group, get_group_id, get_user_id, get_room_id
- TestCmdBounty: lists bounties, handles empty
- TestCmdMy: shows tracked in groups, personal in DM
- TestCmdAdd: add bounty success, validation
- TestCmdUpdate: update bounty, permission denied, invalid ID
- TestCmdDelete: delete bounty, invalid ID
- TestCmdTrack: track in group, reject in DM
- TestCmdUntrack: untrack in group, reject in DM
- TestCmdStart: group vs DM behavior
- TestCmdHelp: shows all commands

Also fix conftest.py to remove obsolete fresh_db fixture
that referenced non-existent db module.

All 55 tests pass.

Addresses han's feedback on PR #33
2026-04-03 13:11:18 +00:00
shokollm
5b1634ebca refactor(commands): use core services instead of storage module
Refactor commands.py to be thin Telegram wrappers around core services.

Changes:
- Replace 'import storage' with imports from core.services and adapters.storage
- Create module-level service instances (BountyService, TrackingService)
- Update format_bounty() to work with Bounty dataclass instead of dict
- Add get_room_id() helper for unified group/DM handling
- Each command handler is now a thin wrapper that:
  1. Extracts Telegram types (update, user_id, room_id)
  2. Calls appropriate core service
  3. Formats and sends response

Kept from original:
- parse_args()
- format_bounty()
- extract_args()

Commands now use services:
- cmd_bounty: BOUNTY_SERVICE.list_bounties()
- cmd_my: BOUNTY_SERVICE.list_bounties() or TRACKING_SERVICE.get_tracked_bounties()
- cmd_add: BOUNTY_SERVICE.add_bounty()
- cmd_update: BOUNTY_SERVICE.update_bounty()
- cmd_delete: BOUNTY_SERVICE.delete_bounty()
- cmd_track: TRACKING_SERVICE.track_bounty() (groups only)
- cmd_untrack: TRACKING_SERVICE.untrack_bounty() (groups only)

Fixes #13
2026-04-03 12:39:23 +00:00
shokollm
7202eeb1d2 feat(cli): implement CLI for jigaido bounty tracker
Create cli/main.py with the following commands:
- add <text> [--link url] [--due date] - Add a new bounty
- list - List all bounties in room
- my - List tracked bounties for user
- update <id> [text] [--link url] [--due date] [--clear-link] [--clear-due]
- delete <id> - Delete a bounty
- track <id> - Track a bounty
- untrack <id> - Untrack a bounty

Context flags:
- --group-id <id> - Group context
- --user-id <id> - User context

Requires either --group-id or --user-id for all commands.

Fixes #11
2026-04-03 12:37:55 +00:00
20 changed files with 1729 additions and 467 deletions

View File

@@ -5,27 +5,42 @@
```bash
git clone https://git.fbrns.co/shoko/jigaido.git
cd jigaido
# Create virtual environment
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Install dependencies
pip install -r apps/telegram-bot/requirements.txt
# Run tests
pytest
# Run bot
export JIGAIDO_BOT_TOKEN="test:token"
python bot.py
export JIGAIDO_BOT_TOKEN="your_bot_token"
python -m apps.telegram-bot.bot
```
## Architecture
JIGAIDO follows hexagonal architecture:
- **Core** (`core/`): Pure domain logic - models, ports (interfaces), and services
- **Adapters** (`adapters/`): Infrastructure implementations - storage adapters
- **Apps** (`apps/`): CLI applications - Telegram bot
## Code Style
- Python (no strict formatter enforced yet)
- Python 3.10+ with type hints
- Async/await for Telegram handlers
- Type hints where obvious
- Docstrings for public functions
- Follow existing code patterns
## Pull Request Workflow
1. Branch from `main`
2. Make changes
3. Test locally
3. Test locally with `pytest`
4. Open PR with description of what changed and why
5. Someone reviews and merges

View File

@@ -22,14 +22,33 @@ A bounty tracking platform. Currently ships with a Telegram bot for managing and
```
jigaido/
├── core/ # Domain layer (pure Python, no external deps)
│ ├── models.py # Domain dataclasses (Bounty, Tracking)
│ ├── ports.py # Port interfaces
│ └── services.py # Domain services
├── adapters/ # Infrastructure adapters
│ └── storage/
│ └── json_file.py # JSON file storage implementation
├── apps/
│ └── telegram-bot/ ← first app (Python)
│ ├── bot.py
── commands.py
│ ├── cron.py
│ ├── db.py
│ └── requirements.txt
└── SPEC.md ← full design specification
│ └── telegram-bot/ # Telegram bot CLI application
│ ├── bot.py # Bot entry point
── commands.py # Command handlers
├── tests/ # Unit tests
├── config.py # Configuration management
└── SPEC.md # Full design specification
```
## Quick Start
```bash
# Install dependencies
pip install -r apps/telegram-bot/requirements.txt
# Set bot token
export JIGAIDO_BOT_TOKEN="your_bot_token"
# Run bot
python -m apps.telegram-bot.bot
```
## License

59
SPEC.md
View File

@@ -20,7 +20,7 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
### Stack
- **Bot**: `python-telegram-bot` (pure Python, no C extensions)
- **Storage**: Per-group JSON files (zero-setup, no DB server)
- **Storage**: Per-group JSON files via `adapters/storage/json_file.py`
- **Date parsing**: `dateparser`
- **Runtime**: Python 3.10+
- **Deployment**: Any $5 VPS with Python 3.10+
@@ -28,19 +28,40 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
### Directory Structure
```
~/.jigaido/ # Data root (~/.jigaido/)
├── {group_id}/
│ ├── group.json # Group bounties
── {user_id}.json # User tracking within this group
└── {user_id}/
└── user.json # User's personal bounties (DM mode)
jigaido/
├── core/ # Domain layer
│ ├── models.py # Domain dataclasses (Bounty, Tracking)
── ports.py # Port interfaces (abstract base classes)
│ └── services.py # Domain services (BountyService, TrackingService)
├── adapters/ # Infrastructure adapters
│ └── storage/
│ └── json_file.py # JSON file storage implementation
├── apps/
│ └── telegram-bot/ # Telegram bot CLI application
│ ├── bot.py # Bot entry point
│ └── commands.py # Command handlers
├── config.py # Configuration management
└── tests/ # Unit tests
```
**Note:** Data directory is at `~/.jigaido/` (home directory), NOT inside the repository or app directory.
### Hexagonal Architecture
### Storage Design
- **Core** (`core/`): Pure domain logic, no external dependencies
- `models.py`: Domain dataclasses
- `ports.py`: Abstract interfaces for storage
- `services.py`: Business logic
**File: `data/{group_id}/group.json`**
- **Adapters** (`adapters/`): Implementations of ports
- `storage/json_file.py`: JSON file-based storage
- **Apps** (`apps/`): CLI applications
- `telegram-bot/`: Telegram bot interface
### Data Storage
Data is stored at `~/.jigaido/` (home directory), NOT inside the repository.
**File: `~/.jigaido/{group_id}/group.json`**
```json
{
@@ -58,7 +79,7 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
}
```
**File: `data/{group_id}/{user_id}.json`**
**File: `~/.jigaido/{group_id}/{user_id}.json`**
```json
{
@@ -68,7 +89,7 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
}
```
**File: `data/{user_id}/user.json`**
**File: `~/.jigaido/{user_id}/user.json`**
```json
{
@@ -87,10 +108,10 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
**Key design decisions:**
1. **Group-isolated storage** — Each group has its own directory. No cross-group access.
2. **Bounty IDs are sequential per group.json** — Not global. Each group's file has its own ID counter.
3. **Atomic writes** — Uses `tempfile` + `rename` for safe writes.
4. **No reminders in v1** — Dropped for simplicity.
1. **Hexagonal architecture** — Core domain is isolated from infrastructure
2. **Group-isolated storage** Each group has its own directory. No cross-group access.
3. **Bounty IDs are sequential per group.json** — Not global. Each group's file has its own ID counter.
4. **Atomic writes** — Uses `tempfile` + `rename` for safe writes.
---
## Commands
@@ -102,7 +123,7 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
| `/bounty` | anyone | List all bounties in this group |
| `/my` | anyone | List bounties tracked by you in this group |
| `/add <text> [link] [due date]` | anyone | Add a new bounty to the group |
| `/update <bounty_id> [text] [link] [due_date]` | creator only | Update an existing bounty |
| `/edit <bounty_id> [text] [link] [due_date]` | creator only | Edit an existing bounty |
| `/delete <bounty_id>` | creator only | Delete a bounty |
| `/track <bounty_id>` | anyone | Track a group bounty |
| `/untrack <bounty_id>` | anyone | Stop tracking a bounty |
@@ -114,7 +135,7 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
| `/bounty` | List all your personal bounties |
| `/my` | List all your personal bounties |
| `/add <text> [link] [due date]` | Add a personal bounty |
| `/update <bounty_id> [text] [link] [due_date]` | Update a personal bounty |
| `/edit <bounty_id> [text] [link] [due_date]` | Edit a personal bounty |
| `/delete <bounty_id>` | Delete a personal bounty |
| `/track <bounty_id>` | Track a personal bounty |
@@ -149,7 +170,7 @@ Stored as Unix timestamp. User-facing display can be localized/converted to any
## Error Handling
- Unknown command → help text with available commands
- `/update`/`/delete` by non-creator → "⛔ Only the creator can edit/delete this bounty."
- `/edit`/`/delete` by non-creator → "⛔ Only the creator can edit/delete this bounty."
- `/track` already tracked → "Already tracking" (idempotent)
- `/untrack` not tracked → "Not tracking" (idempotent)
- Bounty not found → "Bounty not found"

View File

@@ -56,6 +56,8 @@ class JsonFileRoomStorage:
due_date_ts=b.get("due_date_ts"),
created_at=b["created_at"],
created_by_user_id=b["created_by_user_id"],
deleted_at=b.get("deleted_at"),
created_by_username=b.get("created_by_username"),
)
for b in data.get("bounties", [])
]
@@ -64,6 +66,8 @@ class JsonFileRoomStorage:
room_id=data["room_id"],
bounties=bounties,
next_id=data["next_id"],
timezone=data.get("timezone"),
admin_user_ids=data.get("admin_user_ids", []),
)
def save(self, room_data: RoomData) -> None:
@@ -71,6 +75,8 @@ class JsonFileRoomStorage:
data = {
"room_id": room_data.room_id,
"next_id": room_data.next_id,
"timezone": room_data.timezone,
"admin_user_ids": room_data.admin_user_ids or [],
"bounties": [
{
"id": b.id,
@@ -79,6 +85,8 @@ class JsonFileRoomStorage:
"due_date_ts": b.due_date_ts,
"created_at": b.created_at,
"created_by_user_id": b.created_by_user_id,
"deleted_at": b.deleted_at,
"created_by_username": b.created_by_username,
}
for b in room_data.bounties
],
@@ -132,6 +140,35 @@ class JsonFileRoomStorage:
return None
def list_bounties(self, room_id: int) -> list[Bounty]:
"""List all non-deleted bounties in a room.
This is the default method for normal queries - soft-deleted bounties
are excluded from results.
"""
room_data = self.load(room_id)
if room_data is None:
return []
return [b for b in room_data.bounties if b.deleted_at is None]
def list_all_bounties(
self, room_id: int, include_deleted: bool = True
) -> list[Bounty]:
"""List all bounties including or excluding soft-deleted.
Args:
room_id: The room ID
include_deleted: If True, return all bounties including soft-deleted.
If False, return only non-deleted bounties.
Defaults to True for /recover functionality.
"""
room_data = self.load(room_id)
if room_data is None:
return []
if include_deleted:
return room_data.bounties
return [b for b in room_data.bounties if b.deleted_at is None]
class JsonFileTrackingStorage:
"""TrackingStorage implementation using JSON files.

View File

@@ -4,15 +4,20 @@ import logging
import os
import sys
from telegram import Update
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
filters,
)
from telegram.ext import Application, CommandHandler, MessageHandler, filters
import commands
from commands import (
cmd_add,
cmd_bounty,
cmd_delete,
cmd_edit,
cmd_help,
cmd_my,
cmd_start,
cmd_track,
cmd_untrack,
cmd_update,
)
logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
@@ -26,17 +31,18 @@ BOT_TOKEN = os.environ.get("JIGAIDO_BOT_TOKEN", "")
def build_app() -> Application:
app = Application.builder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("start", commands.cmd_start))
app.add_handler(CommandHandler("help", commands.cmd_help))
app.add_handler(CommandHandler("bounty", commands.cmd_bounty))
app.add_handler(CommandHandler("my", commands.cmd_my))
app.add_handler(CommandHandler("add", commands.cmd_add))
app.add_handler(CommandHandler("update", commands.cmd_update))
app.add_handler(CommandHandler("delete", commands.cmd_delete))
app.add_handler(CommandHandler("track", commands.cmd_track))
app.add_handler(CommandHandler("untrack", commands.cmd_untrack))
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("bounty", cmd_bounty))
app.add_handler(CommandHandler("my", cmd_my))
app.add_handler(CommandHandler("add", cmd_add))
app.add_handler(CommandHandler("edit", cmd_edit))
app.add_handler(CommandHandler("update", cmd_update))
app.add_handler(CommandHandler("delete", cmd_delete))
app.add_handler(CommandHandler("track", cmd_track))
app.add_handler(CommandHandler("untrack", cmd_untrack))
app.add_handler(MessageHandler(filters.COMMAND, commands.cmd_help))
app.add_handler(MessageHandler(filters.COMMAND, cmd_help))
return app
@@ -47,6 +53,7 @@ async def post_init(app: Application) -> None:
("bounty", "List bounties"),
("my", "Your tracked bounties"),
("add", "Add a bounty"),
("edit", "Edit a bounty"),
("track", "Track a bounty"),
("untrack", "Stop tracking"),
("help", "Show help"),

View File

@@ -1,9 +1,7 @@
"""Telegram command handlers for JIGAIDO."""
"""Telegram command handlers for JIGAIDO - Thin wrappers around core services."""
import json
import os
import re
import time
from datetime import datetime
from functools import wraps
from typing import Optional
@@ -11,11 +9,45 @@ import dateparser
from telegram import Update
from telegram.ext import ContextTypes
import storage
from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
from core.services import BountyService, TrackingService
ROOM_STORAGE = JsonFileRoomStorage()
TRACKING_STORAGE = JsonFileTrackingStorage()
BOUNTY_SERVICE = BountyService(ROOM_STORAGE)
TRACKING_SERVICE = TrackingService(TRACKING_STORAGE, ROOM_STORAGE)
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]:
if not text:
return []
@@ -45,25 +77,29 @@ def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[
return text, link, due_date_ts
def format_bounty(b: dict, show_id: bool = True) -> str:
def format_bounty(b, show_id: bool = True, room_id: int | None = None) -> str:
parts = []
if show_id:
parts.append(f"[#{b['id']}]")
if b.get("text"):
parts.append(b["text"])
if b.get("link"):
parts.append(f"🔗 {b['link']}")
if b.get("due_date_ts"):
due_str = time.strftime("%Y-%m-%d", time.localtime(b["due_date_ts"]))
days_left = (b["due_date_ts"] - int(time.time())) // 86400
parts.append(f"[#{b.id}]")
if b.text:
parts.append(b.text)
if b.link:
parts.append(f"🔗 {b.link}")
if 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
if days_left < 0:
parts.append(f"{due_str} (OVERDUE)")
elif days_left == 0:
parts.append(f"{due_str} (TODAY)")
else:
parts.append(f"{due_str} ({days_left}d)")
if b.get("created_by_user_id"):
parts.append(f"by {b['created_by_user_id']}")
if b.created_by_user_id:
parts.append(f"by {b.created_by_user_id}")
return " | ".join(parts)
@@ -79,19 +115,26 @@ def get_user_id(update: Update) -> int:
return update.effective_user.id
async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
def get_room_id(update: Update) -> int:
"""Get room_id for the current context.
For groups: negative group_id
For DMs: positive user_id
"""
if is_group(update):
data = storage.load_group_bounties(get_group_id(update))
bounties = data.get("bounties", [])
else:
data = storage.load_user_personal(get_user_id(update))
bounties = data.get("bounties", [])
return get_group_id(update)
return get_user_id(update)
async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
room_id = get_room_id(update)
bounties = BOUNTY_SERVICE.list_bounties(room_id)
if not bounties:
await update.message.reply_text("No bounties yet.")
return
lines = [format_bounty(dict(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)
@@ -100,38 +143,23 @@ async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if is_group(update):
group_id = get_group_id(update)
tracking = storage.load_user_tracking(group_id, user_id)
tracked = tracking.get("tracked", [])
bounties = TRACKING_SERVICE.get_tracked_bounties(group_id, user_id)
room_id = group_id
else:
data = storage.load_user_personal(user_id)
bounties = data.get("bounties", [])
lines = [format_bounty(dict(b), show_id=True) for b in bounties]
await update.message.reply_text(
"\n".join(lines) if lines else "No personal bounties.",
disable_web_page_preview=True,
room_id = get_room_id(update)
bounties = BOUNTY_SERVICE.list_bounties(room_id)
if not bounties:
msg = (
"You are not tracking any bounties."
if is_group(update)
else "No personal bounties."
)
await update.message.reply_text(msg)
return
if not tracked:
await update.message.reply_text("You are not tracking any bounties.")
return
group_data = storage.load_group_bounties(group_id)
bounty_map = {b["id"]: b for b in group_data.get("bounties", [])}
bounty_lines = []
for t in tracked:
bounty = bounty_map.get(t["bounty_id"])
if bounty:
bounty_lines.append(format_bounty(bounty, show_id=True))
if not bounty_lines:
await update.message.reply_text("You are not tracking any bounties.")
return
await update.message.reply_text(
"\n".join(bounty_lines), disable_web_page_preview=True
)
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)
async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -149,19 +177,23 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return
user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update):
group_id = get_group_id(update)
bounty = storage.add_group_bounty(group_id, user_id, text, link, due_date_ts)
else:
bounty = storage.add_personal_bounty(user_id, text, link, due_date_ts)
bounty = BOUNTY_SERVICE.add_bounty(
room_id=room_id,
user_id=user_id,
text=text,
link=link,
due_date_ts=due_date_ts,
)
due_str = ""
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(
f"✅ Bounty added (#{bounty['id']}){due_str}",
f"✅ Bounty added (#{bounty.id}){due_str}",
disable_web_page_preview=True,
)
@@ -186,64 +218,68 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return
user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update):
group_id = get_group_id(update)
bounty = storage.get_group_bounty(group_id, bounty_id)
if not bounty:
await update.message.reply_text("Bounty not found.")
try:
success = BOUNTY_SERVICE.update_bounty(
room_id=room_id,
bounty_id=bounty_id,
user_id=user_id,
text=text,
link=link,
due_date_ts=due_date_ts,
)
except PermissionError as e:
await update.message.reply_text(f"{e}")
return
if bounty["created_by_user_id"] != user_id:
await update.message.reply_text("⛔ Only the creator can edit this bounty.")
return
storage.update_group_bounty(group_id, bounty_id, text, link, due_date_ts)
else:
bounty = storage.get_personal_bounty(user_id, bounty_id)
if not bounty:
await update.message.reply_text("Bounty not found.")
return
storage.update_personal_bounty(user_id, bounty_id, text, link, due_date_ts)
if success:
await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.")
else:
await update.message.reply_text("Bounty not found.")
cmd_edit = cmd_update
async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text)
if not args:
await update.message.reply_text("Usage: /delete <bounty_id>")
await update.message.reply_text("Usage: /delete <bounty_id> [bounty_id ...]")
return
try:
bounty_id = int(args[0])
bounty_ids = [int(arg) for arg in args]
except ValueError:
await update.message.reply_text("Invalid bounty ID.")
await update.message.reply_text("Invalid bounty ID(s).")
return
user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update):
group_id = get_group_id(update)
bounty = storage.get_group_bounty(group_id, bounty_id)
if not bounty:
await update.message.reply_text("Bounty not found.")
return
if bounty["created_by_user_id"] != user_id:
await update.message.reply_text(
"⛔ Only the creator can delete this bounty."
results = BOUNTY_SERVICE.delete_bounties(
room_id=room_id,
bounty_ids=bounty_ids,
user_id=user_id,
)
return
storage.delete_group_bounty(group_id, bounty_id)
else:
bounty = storage.get_personal_bounty(user_id, bounty_id)
if not bounty:
await update.message.reply_text("Bounty not found.")
return
storage.delete_personal_bounty(user_id, bounty_id)
await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.")
lines = []
for bounty_id, result in results.items():
if result == "deleted":
lines.append(f"✅ Bounty #{bounty_id} deleted.")
elif result == "not_found":
lines.append(f"⛔ Bounty #{bounty_id} not found.")
elif result == "permission_denied":
lines.append(f"⛔ Bounty #{bounty_id} - only admins can delete.")
await update.message.reply_text("\n".join(lines))
async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not is_group(update):
await update.message.reply_text("⛔ /track is only available in groups.")
return
args = extract_args(update.message.text)
if not args:
await update.message.reply_text("Usage: /track <bounty_id>")
@@ -256,25 +292,22 @@ async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return
user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update):
group_id = get_group_id(update)
bounty = storage.get_group_bounty(group_id, bounty_id)
if not bounty:
await update.message.reply_text("Bounty not found.")
return
if storage.track_bounty(group_id, user_id, bounty_id):
await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.")
else:
await update.message.reply_text(f"Already tracking bounty #{bounty_id}.")
else:
if storage.track_bounty(user_id, user_id, bounty_id):
try:
if TRACKING_SERVICE.track_bounty(room_id, user_id, bounty_id):
await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.")
else:
await update.message.reply_text(f"Already tracking bounty #{bounty_id}.")
except ValueError as e:
await update.message.reply_text(str(e))
async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not is_group(update):
await update.message.reply_text("⛔ /untrack is only available in groups.")
return
args = extract_args(update.message.text)
if not args:
await update.message.reply_text("Usage: /untrack <bounty_id>")
@@ -287,18 +320,12 @@ async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return
user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update):
group_id = get_group_id(update)
if storage.untrack_bounty(group_id, user_id, bounty_id):
if TRACKING_SERVICE.untrack_bounty(room_id, user_id, bounty_id):
await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.")
else:
await update.message.reply_text(f"Not tracking bounty #{bounty_id}.")
else:
if storage.untrack_bounty(user_id, user_id, bounty_id):
await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.")
else:
await update.message.reply_text(f"Not tracking bounty #{bounty_id}.")
await update.message.reply_text("Not tracking bounty #{bounty_id}.")
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -327,10 +354,10 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
"/bounty — list all bounties\n"
"/my — bounties you're tracking\n"
"/add <text> [link] [due] — add bounty\n"
"/update <id> [text] [link] [due] — update bounty\n"
"/update <id> [text> [link] [due] — update bounty\n"
"/delete <id> — delete bounty\n"
"/track <id> — track a bounty\n"
"/untrack <id> — stop tracking\n"
"/track <id> — track a bounty (groups only)\n"
"/untrack <id> — stop tracking (groups only)\n"
"/start — re-initialize\n"
"/help — this message",
disable_web_page_preview=True,

View File

@@ -1,4 +0,0 @@
python-telegram-bot==21.6
dateparser==1.2.0
pytest==8.3.5
pytest-asyncio==0.25.2

View File

@@ -1,216 +0,0 @@
"""Per-group JSON file storage for JIGAIDO."""
import json
import os
import tempfile
from pathlib import Path
from typing import Optional
DATA_DIR = Path.home() / ".jigaido"
def _ensure_dirs() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
def _group_dir(group_id: int) -> Path:
return DATA_DIR / str(group_id)
def _user_personal_dir(user_id: int) -> Path:
return DATA_DIR / str(user_id)
def _group_file(group_id: int) -> Path:
return _group_dir(group_id) / "group.json"
def _user_tracking_file(group_id: int, user_id: int) -> Path:
return _group_dir(group_id) / f"{user_id}.json"
def _user_personal_file(user_id: int) -> Path:
return _user_personal_dir(user_id) / "user.json"
def _atomic_write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as tmp:
json.dump(data, tmp, indent=2)
tmp_path = tmp.name
os.rename(tmp_path, path)
def load_group_bounties(group_id: int) -> dict:
_ensure_dirs()
path = _group_file(group_id)
if not path.exists():
return {"bounties": [], "next_id": 1}
with open(path) as f:
return json.load(f)
def save_group_bounties(group_id: int, data: dict) -> None:
_atomic_write(_group_file(group_id), data)
def add_group_bounty(
group_id: int,
created_by_user_id: int,
text: Optional[str],
link: Optional[str],
due_date_ts: Optional[int],
) -> dict:
data = load_group_bounties(group_id)
bounty = {
"id": data["next_id"],
"created_by_user_id": created_by_user_id,
"text": text,
"link": link,
"due_date_ts": due_date_ts,
"created_at": int(os.path.getctime(_group_file(group_id)))
if _group_file(group_id).exists()
else 0,
}
data["bounties"].append(bounty)
data["next_id"] += 1
save_group_bounties(group_id, data)
return bounty
def update_group_bounty(
group_id: int,
bounty_id: int,
text: Optional[str],
link: Optional[str],
due_date_ts: Optional[int],
) -> bool:
data = load_group_bounties(group_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
if text is not None:
bounty["text"] = text
if link is not None:
bounty["link"] = link
if due_date_ts is not None:
bounty["due_date_ts"] = due_date_ts
save_group_bounties(group_id, data)
return True
return False
def delete_group_bounty(group_id: int, bounty_id: int) -> bool:
data = load_group_bounties(group_id)
for i, bounty in enumerate(data["bounties"]):
if bounty["id"] == bounty_id:
data["bounties"].pop(i)
save_group_bounties(group_id, data)
return True
return False
def get_group_bounty(group_id: int, bounty_id: int) -> Optional[dict]:
data = load_group_bounties(group_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
return bounty
return None
def load_user_tracking(group_id: int, user_id: int) -> dict:
path = _user_tracking_file(group_id, user_id)
if not path.exists():
return {"tracked": []}
with open(path) as f:
return json.load(f)
def save_user_tracking(group_id: int, user_id: int, data: dict) -> None:
_atomic_write(_user_tracking_file(group_id, user_id), data)
def track_bounty(group_id: int, user_id: int, bounty_id: int) -> bool:
data = load_user_tracking(group_id, user_id)
if any(t["bounty_id"] == bounty_id for t in data["tracked"]):
return False
data["tracked"].append({"bounty_id": bounty_id})
save_user_tracking(group_id, user_id, data)
return True
def untrack_bounty(group_id: int, user_id: int, bounty_id: int) -> bool:
data = load_user_tracking(group_id, user_id)
for i, t in enumerate(data["tracked"]):
if t["bounty_id"] == bounty_id:
data["tracked"].pop(i)
save_user_tracking(group_id, user_id, data)
return True
return False
def load_user_personal(user_id: int) -> dict:
path = _user_personal_file(user_id)
if not path.exists():
return {"bounties": [], "next_id": 1}
with open(path) as f:
return json.load(f)
def save_user_personal(user_id: int, data: dict) -> None:
_atomic_write(_user_personal_file(user_id), data)
def add_personal_bounty(
user_id: int, text: Optional[str], link: Optional[str], due_date_ts: Optional[int]
) -> dict:
data = load_user_personal(user_id)
bounty = {
"id": data["next_id"],
"text": text,
"link": link,
"due_date_ts": due_date_ts,
"created_at": 0,
}
data["bounties"].append(bounty)
data["next_id"] += 1
save_user_personal(user_id, data)
return bounty
def update_personal_bounty(
user_id: int,
bounty_id: int,
text: Optional[str],
link: Optional[str],
due_date_ts: Optional[int],
) -> bool:
data = load_user_personal(user_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
if text is not None:
bounty["text"] = text
if link is not None:
bounty["link"] = link
if due_date_ts is not None:
bounty["due_date_ts"] = due_date_ts
save_user_personal(user_id, data)
return True
return False
def delete_personal_bounty(user_id: int, bounty_id: int) -> bool:
data = load_user_personal(user_id)
for i, bounty in enumerate(data["bounties"]):
if bounty["id"] == bounty_id:
data["bounties"].pop(i)
save_user_personal(user_id, data)
return True
return False
def get_personal_bounty(user_id: int, bounty_id: int) -> Optional[dict]:
data = load_user_personal(user_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
return bounty
return None

View File

@@ -6,22 +6,5 @@ from pathlib import Path
import pytest
# Add the app directory to path so `import db` works when running pytest
# Add the app directory to path so imports work when running pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch):
"""Replace DB_PATH with a temp file before any test runs."""
import db as _db
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
tmp_path = Path(tmp.name)
tmp.close()
monkeypatch.setattr(_db, "DB_PATH", tmp_path)
_db.init_db()
yield tmp_path
tmp_path.unlink(missing_ok=True)

View File

@@ -1,11 +1,33 @@
"""Tests for commands.py — parsing and formatting functions only."""
"""Tests for commands.py — parsing, formatting, and command handlers."""
import time
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch, AsyncMock, sentinel
import pytest
from commands import extract_args, parse_args, format_bounty
from telegram import Update, Message, User, Chat, CallbackQuery
from telegram.ext import ContextTypes
from commands import (
extract_args,
parse_args,
format_bounty,
cmd_bounty,
cmd_my,
cmd_add,
cmd_update,
cmd_delete,
cmd_track,
cmd_untrack,
cmd_start,
cmd_help,
is_group,
get_group_id,
get_user_id,
get_room_id,
BOUNTY_SERVICE,
TRACKING_SERVICE,
)
class TestExtractArgs:
@@ -110,13 +132,11 @@ class TestFormatBounty:
created_by_user_id=123456,
):
row = MagicMock()
row.__getitem__ = lambda s, k: {
"id": id,
"text": text,
"link": link,
"due_date_ts": due_date_ts,
"created_by_user_id": created_by_user_id,
}[k]
row.id = id
row.text = text
row.link = link
row.due_date_ts = due_date_ts
row.created_by_user_id = created_by_user_id
return row
def test_shows_id(self):
@@ -168,3 +188,378 @@ class TestFormatBounty:
b = self._row(created_by_user_id=999)
out = format_bounty(b)
assert "999" in out
def create_mock_update(
user_id=123,
chat_id=-456,
chat_type="group",
message_text="/bounty",
):
"""Create a mock Telegram Update with common values."""
user = MagicMock(spec=User)
user.id = user_id
chat = MagicMock(spec=Chat)
chat.id = chat_id
chat.type = chat_type
message = MagicMock(spec=Message)
message.text = message_text
message.reply_text = AsyncMock()
message.user = user
update = MagicMock(spec=Update)
update.effective_user = user
update.effective_chat = chat
update.message = message
return update
class TestHelperFunctions:
"""Test helper functions."""
def test_is_group_true(self):
update = create_mock_update(chat_type="group")
assert is_group(update) is True
def test_is_group_false_for_private(self):
update = create_mock_update(chat_type="private")
assert is_group(update) is False
def test_get_group_id(self):
update = create_mock_update(chat_id=-789)
assert get_group_id(update) == -789
def test_get_user_id(self):
update = create_mock_update(user_id=999)
assert get_user_id(update) == 999
def test_get_room_id_group(self):
update = create_mock_update(chat_id=-456, chat_type="group", user_id=123)
assert get_room_id(update) == -456
def test_get_room_id_private(self):
update = create_mock_update(chat_id=123, chat_type="private", user_id=123)
assert get_room_id(update) == 123
class TestCmdBounty:
"""Test cmd_bounty command."""
@pytest.mark.asyncio
async def test_lists_bounties(self):
update = create_mock_update()
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
mock_bounty = MagicMock()
mock_bounty.id = 1
mock_bounty.text = "Test"
mock_bounty.link = None
mock_bounty.due_date_ts = None
mock_bounty.created_by_user_id = 123
with patch.object(
BOUNTY_SERVICE, "list_bounties", return_value=[mock_bounty]
) as mock_list:
await cmd_bounty(update, ctx)
mock_list.assert_called_once_with(-456)
update.message.reply_text.assert_called_once()
call_args = update.message.reply_text.call_args[0][0]
assert "[#1]" in call_args
assert "Test" in call_args
@pytest.mark.asyncio
async def test_no_bounties(self):
update = create_mock_update()
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(BOUNTY_SERVICE, "list_bounties", return_value=[]):
await cmd_bounty(update, ctx)
update.message.reply_text.assert_called_once_with("No bounties yet.")
class TestCmdMy:
"""Test cmd_my command."""
@pytest.mark.asyncio
async def test_in_group_shows_tracked(self):
update = create_mock_update(chat_type="group")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
mock_bounty = MagicMock()
mock_bounty.id = 1
mock_bounty.text = "Tracked"
mock_bounty.link = None
mock_bounty.due_date_ts = None
mock_bounty.created_by_user_id = 123
with patch.object(
TRACKING_SERVICE, "get_tracked_bounties", return_value=[mock_bounty]
) as mock_track:
await cmd_my(update, ctx)
mock_track.assert_called_once_with(-456, 123)
update.message.reply_text.assert_called_once()
@pytest.mark.asyncio
async def test_in_private_shows_personal(self):
update = create_mock_update(chat_type="private", chat_id=123)
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
mock_bounty = MagicMock()
mock_bounty.id = 2
mock_bounty.text = "Personal"
mock_bounty.link = None
mock_bounty.due_date_ts = None
mock_bounty.created_by_user_id = 123
with patch.object(
BOUNTY_SERVICE, "list_bounties", return_value=[mock_bounty]
) as mock_list:
await cmd_my(update, ctx)
mock_list.assert_called_once_with(123)
update.message.reply_text.assert_called_once()
@pytest.mark.asyncio
async def test_no_bounties_tracked(self):
update = create_mock_update(chat_type="group")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(TRACKING_SERVICE, "get_tracked_bounties", return_value=[]):
await cmd_my(update, ctx)
update.message.reply_text.assert_called_once_with(
"You are not tracking any bounties."
)
class TestCmdAdd:
"""Test cmd_add command."""
@pytest.mark.asyncio
async def test_add_bounty_success(self):
update = create_mock_update(message_text="/add Fix the bug")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
mock_bounty = MagicMock()
mock_bounty.id = 42
with patch.object(
BOUNTY_SERVICE, "add_bounty", return_value=mock_bounty
) as mock_add:
await cmd_add(update, ctx)
mock_add.assert_called_once()
call_kwargs = mock_add.call_args[1]
assert call_kwargs["room_id"] == -456
assert call_kwargs["user_id"] == 123
assert call_kwargs["text"] == "Fix the bug"
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_add_without_args(self):
update = create_mock_update(message_text="/add")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_add(update, ctx)
update.message.reply_text.assert_called_once()
assert "Usage" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_add_needs_text_or_link(self):
update = create_mock_update(message_text="/add")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
_, link, _ = parse_args([])
if not "test" and not link:
await update.message.reply_text("A bounty needs at least text or a link.")
update.message.reply_text.assert_called_once()
class TestCmdUpdate:
"""Test cmd_update command."""
@pytest.mark.asyncio
async def test_update_bounty_success(self):
update = create_mock_update(message_text="/update 1 New text")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(
BOUNTY_SERVICE, "update_bounty", return_value=True
) as mock_update:
await cmd_update(update, ctx)
mock_update.assert_called_once()
call_kwargs = mock_update.call_args[1]
assert call_kwargs["room_id"] == -456
assert call_kwargs["bounty_id"] == 1
assert call_kwargs["text"] == "New text"
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_update_without_args(self):
update = create_mock_update(message_text="/update")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_update(update, ctx)
update.message.reply_text.assert_called_once()
assert "Usage" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_update_invalid_id(self):
update = create_mock_update(message_text="/update abc")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_update(update, ctx)
update.message.reply_text.assert_called_once_with("Invalid bounty ID.")
@pytest.mark.asyncio
async def test_update_permission_denied(self):
update = create_mock_update(message_text="/update 1 new text")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(
BOUNTY_SERVICE,
"update_bounty",
side_effect=PermissionError("Not your bounty"),
):
await cmd_update(update, ctx)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
class TestCmdDelete:
"""Test cmd_delete command."""
@pytest.mark.asyncio
async def test_delete_bounty_success(self):
update = create_mock_update(message_text="/delete 1")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(
BOUNTY_SERVICE, "delete_bounty", return_value=True
) as mock_delete:
await cmd_delete(update, ctx)
mock_delete.assert_called_once_with(room_id=-456, bounty_id=1, user_id=123)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_delete_without_args(self):
update = create_mock_update(message_text="/delete")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_delete(update, ctx)
update.message.reply_text.assert_called_once()
assert "Usage" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_delete_invalid_id(self):
update = create_mock_update(message_text="/delete abc")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_delete(update, ctx)
update.message.reply_text.assert_called_once_with("Invalid bounty ID.")
class TestCmdTrack:
"""Test cmd_track command."""
@pytest.mark.asyncio
async def test_track_in_group(self):
update = create_mock_update(chat_type="group", message_text="/track 1")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(
TRACKING_SERVICE, "track_bounty", return_value=True
) as mock_track:
await cmd_track(update, ctx)
mock_track.assert_called_once_with(-456, 123, 1)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_track_not_in_group(self):
update = create_mock_update(chat_type="private", message_text="/track 1")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_track(update, ctx)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_track_without_args(self):
update = create_mock_update(chat_type="group", message_text="/track")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_track(update, ctx)
update.message.reply_text.assert_called_once()
assert "Usage" in update.message.reply_text.call_args[0][0]
class TestCmdUntrack:
"""Test cmd_untrack command."""
@pytest.mark.asyncio
async def test_untrack_in_group(self):
update = create_mock_update(chat_type="group", message_text="/untrack 1")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(
TRACKING_SERVICE, "untrack_bounty", return_value=True
) as mock_untrack:
await cmd_untrack(update, ctx)
mock_untrack.assert_called_once_with(-456, 123, 1)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_untrack_not_in_group(self):
update = create_mock_update(chat_type="private", message_text="/untrack 1")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_untrack(update, ctx)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
class TestCmdStart:
"""Test cmd_start command."""
@pytest.mark.asyncio
async def test_start_in_group(self):
update = create_mock_update(chat_type="group")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_start(update, ctx)
update.message.reply_text.assert_called_once()
text = update.message.reply_text.call_args[0][0]
assert "👻" in text
assert "/bounty" in text
@pytest.mark.asyncio
async def test_start_in_private(self):
update = create_mock_update(chat_type="private")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_start(update, ctx)
update.message.reply_text.assert_called_once()
text = update.message.reply_text.call_args[0][0]
assert "👻" in text
assert "/my" in text
class TestCmdHelp:
"""Test cmd_help command."""
@pytest.mark.asyncio
async def test_help_shows_commands(self):
update = create_mock_update()
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_help(update, ctx)
update.message.reply_text.assert_called_once()
text = update.message.reply_text.call_args[0][0]
assert "/bounty" in text
assert "/add" in text
assert "/help" in text

251
cli/main.py Normal file
View File

@@ -0,0 +1,251 @@
"""JIGAIDO CLI - Command line interface for bounty tracking."""
import argparse
import sys
from pathlib import Path
import dateparser
from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
from config import Config
from core.services import BountyService, TrackingService
def parse_due_date(due_str: str | None) -> int | None:
"""Parse due date string to Unix timestamp."""
if not due_str:
return None
parsed = dateparser.parse(due_str)
if parsed:
return int(parsed.timestamp())
return None
def create_services():
"""Create service instances with storage."""
config = Config()
config.ensure_data_dir()
room_storage = JsonFileRoomStorage(config.data_dir / "room")
tracking_storage = JsonFileTrackingStorage(config.data_dir / "tracking")
bounty_service = BountyService(room_storage)
tracking_service = TrackingService(tracking_storage, room_storage)
return bounty_service, tracking_service
def cmd_add(args):
"""Add a new bounty."""
bounty_service, _ = create_services()
due_ts = parse_due_date(args.due)
bounty = bounty_service.add_bounty(
room_id=args.group_id or args.user_id,
user_id=args.user_id or 0,
text=args.text,
link=args.link,
due_date_ts=due_ts,
)
print(f"Added bounty #{bounty.id}")
def cmd_list(args):
"""List all bounties in a room."""
bounty_service, _ = create_services()
bounties = bounty_service.list_bounties(room_id=args.group_id or args.user_id)
if not bounties:
print("No bounties")
return
for b in bounties:
due_str = (
f", due: {dateparser.parse(str(b.due_date_ts)).strftime('%Y-%m-%d')}"
if b.due_date_ts
else ""
)
link_str = f", link: {b.link}" if b.link else ""
print(f"#{b.id}: {b.text or '(no text)'}{link_str}{due_str}")
def cmd_my(args):
"""List tracked bounties for a user."""
_, tracking_service = create_services()
room_id = args.group_id or args.user_id
tracked = tracking_service.get_tracked_bounties(
room_id=room_id, user_id=args.user_id
)
if not tracked:
print("Not tracking any bounties")
return
for b in tracked:
due_str = (
f", due: {dateparser.parse(str(b.due_date_ts)).strftime('%Y-%m-%d')}"
if b.due_date_ts
else ""
)
link_str = f", link: {b.link}" if b.link else ""
print(f"#{b.id}: {b.text or '(no text)'}{link_str}{due_str}")
def cmd_update(args):
"""Update a bounty."""
bounty_service, _ = create_services()
due_ts = parse_due_date(args.due)
try:
success = bounty_service.update_bounty(
room_id=args.group_id or args.user_id,
bounty_id=args.bounty_id,
user_id=args.user_id or 0,
text=args.text,
link=args.link,
due_date_ts=due_ts,
clear_link=args.clear_link,
clear_due=args.clear_due,
)
if success:
print(f"Updated bounty #{args.bounty_id}")
else:
print(f"Bounty #{args.bounty_id} not found", file=sys.stderr)
sys.exit(1)
except PermissionError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_delete(args):
"""Delete a bounty."""
bounty_service, _ = create_services()
try:
success = bounty_service.delete_bounty(
room_id=args.group_id or args.user_id,
bounty_id=args.bounty_id,
user_id=args.user_id or 0,
)
if success:
print(f"Deleted bounty #{args.bounty_id}")
else:
print(f"Bounty #{args.bounty_id} not found", file=sys.stderr)
sys.exit(1)
except PermissionError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_track(args):
"""Track a bounty."""
_, tracking_service = create_services()
try:
success = tracking_service.track_bounty(
room_id=args.group_id,
user_id=args.user_id,
bounty_id=args.bounty_id,
)
if success:
print(f"Tracking bounty #{args.bounty_id}")
else:
print(f"Already tracking bounty #{args.bounty_id}")
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_untrack(args):
"""Untrack a bounty."""
_, tracking_service = create_services()
success = tracking_service.untrack_bounty(
room_id=args.group_id,
user_id=args.user_id,
bounty_id=args.bounty_id,
)
if success:
print(f"Untracked bounty #{args.bounty_id}")
else:
print(f"Not tracking bounty #{args.bounty_id}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
prog="jigaido-cli", description="JIGAIDO bounty tracker CLI"
)
subparsers = parser.add_subparsers(dest="command", help="Commands")
parser_add = subparsers.add_parser("add", help="Add a new bounty")
parser_add.add_argument("text", help="Bounty description")
parser_add.add_argument("--link", help="Optional link")
parser_add.add_argument(
"--due", help="Optional due date (e.g., 'tomorrow', 'in 3 days')"
)
parser_list = subparsers.add_parser("list", help="List all bounties")
parser_my = subparsers.add_parser("my", help="List tracked bounties")
parser_update = subparsers.add_parser("update", help="Update a bounty")
parser_update.add_argument("bounty_id", type=int, help="Bounty ID to update")
parser_update.add_argument("text", nargs="?", help="New description")
parser_update.add_argument("--link", help="New link")
parser_update.add_argument("--due", help="New due date")
parser_update.add_argument("--clear-link", action="store_true", help="Clear link")
parser_update.add_argument(
"--clear-due", action="store_true", help="Clear due date"
)
parser_delete = subparsers.add_parser("delete", help="Delete a bounty")
parser_delete.add_argument("bounty_id", type=int, help="Bounty ID to delete")
parser_track = subparsers.add_parser("track", help="Track a bounty")
parser_track.add_argument("bounty_id", type=int, help="Bounty ID to track")
parser_untrack = subparsers.add_parser("untrack", help="Untrack a bounty")
parser_untrack.add_argument("bounty_id", type=int, help="Bounty ID to untrack")
for sp in [
parser_add,
parser_list,
parser_my,
parser_update,
parser_delete,
parser_track,
parser_untrack,
]:
sp.add_argument(
"--group-id", type=int, help="Group context (use group room ID)"
)
sp.add_argument(
"--user-id", type=int, help="User context (use Telegram user ID)"
)
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
if not args.group_id and not args.user_id:
print("Error: either --group-id or --user-id is required", file=sys.stderr)
sys.exit(1)
if args.command in ("add", "list", "my", "update", "delete"):
if not (args.group_id or args.user_id):
print("Error: --group-id or --user-id required", file=sys.stderr)
sys.exit(1)
if args.command == "add" and not args.text:
print("Error: text is required for add", file=sys.stderr)
sys.exit(1)
command_map = {
"add": cmd_add,
"list": cmd_list,
"my": cmd_my,
"update": cmd_update,
"delete": cmd_delete,
"track": cmd_track,
"untrack": cmd_untrack,
}
if args.command in command_map:
command_map[args.command](args)
else:
print(f"Unknown command: {args.command}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -9,6 +9,9 @@ class Bounty:
The created_by_user_id field always refers to the user who created the bounty.
It does NOT indicate whether the bounty is a group or personal bounty.
The deleted_at field indicates soft-delete: None means not deleted,
a value means deleted at that Unix timestamp.
"""
id: int
@@ -17,6 +20,8 @@ class Bounty:
due_date_ts: int | None
created_at: int
created_by_user_id: int
deleted_at: int | None = None
created_by_username: str | None = None
@dataclass
@@ -37,11 +42,20 @@ class RoomData:
The room_id can be negative for Telegram groups or positive for DMs.
The next_id field is used to generate unique bounty IDs within this room.
The timezone field stores the room's timezone (e.g., "Asia/Jakarta"), default UTC+0.
The admin_user_ids field lists users who have admin privileges in this room.
"""
room_id: int
bounties: list[Bounty]
next_id: int
timezone: str | None = None
admin_user_ids: list[int] | None = None
def __post_init__(self):
if self.admin_user_ids is None:
self.admin_user_ids = []
@dataclass

View File

@@ -40,6 +40,25 @@ class RoomStorage(Protocol):
"""Get a specific bounty from a room by ID."""
...
def list_bounties(self, room_id: int) -> list[Bounty]:
"""List all non-deleted bounties in a room.
Soft-deleted bounties (where deleted_at is not None) are excluded.
"""
...
def list_all_bounties(
self, room_id: int, include_deleted: bool = True
) -> list[Bounty]:
"""List all bounties including or excluding soft-deleted.
Args:
room_id: The room ID
include_deleted: If True, return all bounties including soft-deleted.
If False, return only non-deleted bounties.
"""
...
@runtime_checkable
class TrackingStorage(Protocol):

View File

@@ -15,11 +15,103 @@ class BountyService:
- Positive room_id: DM/personal context (user's Telegram 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):
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
) -> bool:
"""Check if a link is unique within a room (not used by another bounty)."""
if not link:
return True
room_data = self._storage.load(room_id)
if room_data is None:
return True
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 False
return True
def add_bounty(
self,
room_id: int,
@@ -28,7 +120,13 @@ class BountyService:
link: Optional[str] = None,
due_date_ts: Optional[int] = None,
) -> 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.")
if not self.check_link_unique(room_id, link):
raise ValueError("A bounty with this link already exists in this room.")
room_data = self._storage.load(room_id)
if room_data is None:
room_data = RoomData(room_id=room_id, bounties=[], next_id=1)
@@ -47,15 +145,20 @@ class BountyService:
return bounty
def list_bounties(self, room_id: int) -> list[Bounty]:
"""List all bounties in a room."""
room_data = self._storage.load(room_id)
if room_data is None:
return []
return room_data.bounties
"""List all non-deleted bounties in a room."""
return self._storage.list_bounties(room_id)
def list_deleted_bounties(self, room_id: int) -> list[Bounty]:
"""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:
"""Get a specific bounty by ID."""
return self._storage.get_bounty(room_id, bounty_id)
"""Get a specific bounty by ID. Excludes soft-deleted bounties."""
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(
self,
@@ -68,12 +171,17 @@ class BountyService:
clear_link: bool = False,
clear_due: bool = False,
) -> bool:
"""Update a bounty. Only creator can update."""
"""Update a bounty. Only admins can update."""
bounty = self._storage.get_bounty(room_id, bounty_id)
if not bounty:
return False
if bounty.created_by_user_id != user_id:
raise PermissionError("Only the creator can edit this bounty.")
if not self.is_admin(room_id, user_id):
raise PermissionError("Only admins can edit bounties.")
if link and not self.check_link_unique(
room_id, link, exclude_bounty_id=bounty_id
):
raise ValueError("A bounty with this link already exists in this room.")
updated = Bounty(
id=bounty.id,
@@ -84,21 +192,49 @@ class BountyService:
if clear_due
else (due_date_ts if due_date_ts is not None else bounty.due_date_ts),
created_at=bounty.created_at,
deleted_at=bounty.deleted_at,
created_by_username=bounty.created_by_username,
)
self._storage.update_bounty(room_id, updated)
return True
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)
if not bounty:
return False
if bounty.created_by_user_id != user_id:
raise PermissionError("Only the creator can delete this bounty.")
if not self.is_admin(room_id, user_id):
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
def delete_bounties(
self, room_id: int, bounty_ids: list[int], user_id: int
) -> dict[int, str]:
"""Soft delete multiple bounties. Only admins can delete.
Returns a dict mapping bounty_id to result:
- "deleted": Successfully soft-deleted
- "not_found": Bounty does not exist
- "permission_denied": User is not admin
"""
results = {}
for bounty_id in bounty_ids:
bounty = self._storage.get_bounty(room_id, bounty_id)
if not bounty:
results[bounty_id] = "not_found"
continue
if not self.is_admin(room_id, user_id):
results[bounty_id] = "permission_denied"
continue
bounty.deleted_at = int(time.time())
self._storage.update_bounty(room_id, bounty)
results[bounty_id] = "deleted"
return results
class TrackingService:
"""Service for tracking bounty operations."""
@@ -147,7 +283,7 @@ class TrackingService:
if room_data is None:
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 [
bounty_map[t.bounty_id]
for t in tracking_data.tracked

0
tests/__init__.py Normal file
View File

376
tests/test_cli.py Normal file
View File

@@ -0,0 +1,376 @@
"""Tests for cli/main.py — CLI commands with mocked dependencies."""
import pytest
from unittest.mock import patch, MagicMock
from io import StringIO
import sys
from core.models import Bounty
from core.ports import RoomStorage, TrackingStorage
class MockRoomStorage(RoomStorage):
"""Mock RoomStorage for testing."""
def __init__(self):
self._rooms: dict[int, dict] = {}
def load(self, room_id: int) -> dict | None:
return self._rooms.get(room_id)
def save(self, room_data: dict) -> None:
self._rooms[room_data["room_id"]] = room_data
def add_bounty(self, room_id: int, bounty: Bounty) -> None:
if room_id not in self._rooms:
self._rooms[room_id] = {"room_id": room_id, "bounties": [], "next_id": 1}
self._rooms[room_id]["bounties"].append(bounty)
def update_bounty(self, room_id: int, bounty: Bounty) -> None:
if room_id in self._rooms:
for i, b in enumerate(self._rooms[room_id]["bounties"]):
if b.id == bounty.id:
self._rooms[room_id]["bounties"][i] = bounty
break
def delete_bounty(self, room_id: int, bounty_id: int) -> None:
if room_id in self._rooms:
self._rooms[room_id]["bounties"] = [
b for b in self._rooms[room_id]["bounties"] if b.id != bounty_id
]
def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
if room_id in self._rooms:
for b in self._rooms[room_id]["bounties"]:
if b.id == bounty_id:
return b
return None
class MockTrackingStorage(TrackingStorage):
"""Mock TrackingStorage for testing."""
def __init__(self):
self._tracking: dict[int, dict] = {}
def load(self, user_id: int) -> dict | None:
return self._tracking.get(user_id)
def save(self, tracking_data: dict) -> None:
self._tracking[tracking_data["user_id"]] = tracking_data
def track_bounty(self, user_id: int, bounty_id: int, room_id: int) -> bool:
if user_id not in self._tracking:
self._tracking[user_id] = {"user_id": user_id, "bounty_ids": []}
if bounty_id not in self._tracking[user_id]["bounty_ids"]:
self._tracking[user_id]["bounty_ids"].append(bounty_id)
return True
return False
def untrack_bounty(self, user_id: int, bounty_id: int) -> bool:
if user_id in self._tracking:
if bounty_id in self._tracking[user_id]["bounty_ids"]:
self._tracking[user_id]["bounty_ids"].remove(bounty_id)
return True
return False
def get_tracked_bounty_ids(self, user_id: int) -> list[int]:
if user_id in self._tracking:
return self._tracking[user_id]["bounty_ids"]
return []
@pytest.fixture
def mock_services():
"""Create mock services for CLI testing."""
room_storage = MockRoomStorage()
tracking_storage = MockTrackingStorage()
return room_storage, tracking_storage
class TestCLIParsing:
"""Test CLI argument parsing through main()."""
def test_add_command_requires_text(self):
"""Test that 'add' command requires text argument."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "add", "--group-id=-1001"]):
with patch("cli.main.create_services"):
with patch("sys.stderr", new=StringIO()) as stderr:
with pytest.raises(SystemExit):
main()
assert "arguments are required: text" in stderr.getvalue()
def test_list_command(self):
"""Test 'list' command parsing."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "list", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_create.return_value = (MagicMock(), MagicMock())
mock_bounty_service = MagicMock()
mock_bounty_service.list_bounties.return_value = []
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "No bounties" in stdout.getvalue()
def test_my_command(self):
"""Test 'my' command parsing."""
from cli.main import main
with patch(
"sys.argv", ["jigaido-cli", "my", "--group-id=-1001", "--user-id=123"]
):
with patch("cli.main.create_services") as mock_create:
mock_tracking_service = MagicMock()
mock_tracking_service.get_tracked_bounties.return_value = []
mock_create.return_value = (MagicMock(), mock_tracking_service)
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Not tracking any bounties" in stdout.getvalue()
def test_update_command(self):
"""Test 'update' command parsing."""
from cli.main import main
with patch(
"sys.argv", ["jigaido-cli", "update", "1", "--group-id=-1001", "new text"]
):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.update_bounty.return_value = True
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Updated bounty #1" in stdout.getvalue()
def test_delete_command(self):
"""Test 'delete' command parsing."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "delete", "1", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.delete_bounty.return_value = True
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Deleted bounty #1" in stdout.getvalue()
def test_track_command(self):
"""Test 'track' command parsing."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "track", "1", "--group-id=-1001", "--user-id=123"],
):
with patch("cli.main.create_services") as mock_create:
mock_tracking_service = MagicMock()
mock_tracking_service.track_bounty.return_value = True
mock_create.return_value = (MagicMock(), mock_tracking_service)
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Tracking bounty #1" in stdout.getvalue()
def test_untrack_command(self):
"""Test 'untrack' command parsing."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "untrack", "1", "--group-id=-1001", "--user-id=123"],
):
with patch("cli.main.create_services") as mock_create:
mock_tracking_service = MagicMock()
mock_tracking_service.untrack_bounty.return_value = True
mock_create.return_value = (MagicMock(), mock_tracking_service)
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Untracked bounty #1" in stdout.getvalue()
class TestCLIValidation:
"""Test CLI input validation."""
def test_requires_group_id_or_user_id(self):
"""Test that commands require either --group-id or --user-id."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "add", "some text"]):
with patch("sys.stderr", new=StringIO()) as stderr:
with pytest.raises(SystemExit):
main()
assert "group-id or --user-id is required" in stderr.getvalue()
def test_unknown_command(self):
"""Test handling of unknown commands."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "unknown_cmd", "--group-id=-1001"]):
with patch("sys.stderr", new=StringIO()) as stderr:
with pytest.raises(SystemExit):
main()
assert "invalid choice" in stderr.getvalue()
def test_update_clear_link_flag(self):
"""Test update with --clear-link flag."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "update", "1", "--clear-link", "--group-id=-1001"],
):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.update_bounty.return_value = True
mock_create.return_value = (mock_bounty_service, MagicMock())
main()
mock_bounty_service.update_bounty.assert_called_once()
call_kwargs = mock_bounty_service.update_bounty.call_args
assert call_kwargs.kwargs.get("clear_link") == True
def test_update_clear_due_flag(self):
"""Test update with --clear-due flag."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "update", "1", "--clear-due", "--group-id=-1001"],
):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.update_bounty.return_value = True
mock_create.return_value = (mock_bounty_service, MagicMock())
main()
mock_bounty_service.update_bounty.assert_called_once()
call_kwargs = mock_bounty_service.update_bounty.call_args
assert call_kwargs.kwargs.get("clear_due") == True
class TestCLIOutput:
"""Test CLI output formatting."""
def test_list_shows_bounties(self):
"""Test that 'list' shows bounty details correctly."""
from cli.main import main
mock_bounty = MagicMock()
mock_bounty.id = 1
mock_bounty.text = "Fix bug"
mock_bounty.link = "https://github.com/issue/1"
mock_bounty.due_date_ts = None
with patch("sys.argv", ["jigaido-cli", "list", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.list_bounties.return_value = [mock_bounty]
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
output = stdout.getvalue()
assert "#1:" in output
assert "Fix bug" in output
assert "https://github.com/issue/1" in output
def test_list_empty(self):
"""Test that 'list' shows 'No bounties' when empty."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "list", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.list_bounties.return_value = []
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "No bounties" in stdout.getvalue()
def test_add_output(self):
"""Test that 'add' outputs the new bounty ID."""
from cli.main import main
mock_bounty = MagicMock()
mock_bounty.id = 42
with patch("sys.argv", ["jigaido-cli", "add", "New task", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.add_bounty.return_value = mock_bounty
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Added bounty #42" in stdout.getvalue()
class TestCLIErrorHandling:
"""Test CLI error handling."""
def test_delete_nonexistent_bounty(self):
"""Test deleting a non-existent bounty."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "delete", "999", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.delete_bounty.return_value = False
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stderr", new=StringIO()) as stderr:
with pytest.raises(SystemExit):
main()
assert "not found" in stderr.getvalue()
def test_update_permission_error(self):
"""Test update with permission error."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "update", "1", "new text", "--group-id=-1001"],
):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.update_bounty.side_effect = PermissionError(
"Not owner"
)
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stderr", new=StringIO()) as stderr:
with pytest.raises(SystemExit):
main()
assert "Not owner" in stderr.getvalue()
def test_track_already_tracking(self):
"""Test tracking a bounty that's already tracked."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "track", "1", "--group-id=-1001", "--user-id=123"],
):
with patch("cli.main.create_services") as mock_create:
mock_tracking_service = MagicMock()
mock_tracking_service.track_bounty.return_value = False
mock_create.return_value = (MagicMock(), mock_tracking_service)
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Already tracking" in stdout.getvalue()
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -28,6 +28,22 @@ class TestBounty:
assert b.due_date_ts == 1735689600
assert b.created_at == 1735603200
assert b.created_by_user_id == 123
assert b.deleted_at is None
assert b.created_by_username is None
def test_bounty_with_new_fields(self):
b = Bounty(
id=1,
text="Fix the bug",
link="https://github.com/example/repo/issues/1",
due_date_ts=1735689600,
created_at=1735603200,
created_by_user_id=123,
deleted_at=1736200000,
created_by_username="johndoe",
)
assert b.deleted_at == 1736200000
assert b.created_by_username == "johndoe"
def test_bounty_optional_fields_can_be_none(self):
b = Bounty(
@@ -41,6 +57,8 @@ class TestBounty:
assert b.text is None
assert b.link is None
assert b.due_date_ts is None
assert b.deleted_at is None
assert b.created_by_username is None
def test_bounty_comparison_equal(self):
b1 = Bounty(
@@ -103,6 +121,8 @@ class TestRoomData:
assert rd.room_id == -1001
assert rd.bounties == []
assert rd.next_id == 1
assert rd.timezone is None
assert rd.admin_user_ids == []
def test_create_dm_room_data(self):
rd = RoomData(
@@ -113,6 +133,8 @@ class TestRoomData:
assert rd.room_id == 123456
assert rd.bounties == []
assert rd.next_id == 1
assert rd.timezone is None
assert rd.admin_user_ids == []
def test_room_data_with_bounties(self):
b = Bounty(
@@ -128,6 +150,25 @@ class TestRoomData:
assert rd.bounties[0].text == "Task"
assert rd.bounties[0].created_by_user_id == 123
def test_room_data_with_new_fields(self):
rd = RoomData(
room_id=-1001,
bounties=[],
next_id=1,
timezone="Asia/Jakarta",
admin_user_ids=[123, 456],
)
assert rd.timezone == "Asia/Jakarta"
assert rd.admin_user_ids == [123, 456]
def test_room_data_admin_user_ids_defaults_to_empty_list(self):
rd = RoomData(
room_id=-1001,
bounties=[],
next_id=1,
)
assert rd.admin_user_ids == []
class TestTrackingData:
def test_create_tracking_data(self):

View File

@@ -48,6 +48,20 @@ class SimpleRoomStorage:
return b
return None
def list_bounties(self, room_id: int) -> list[Bounty]:
if room_id not in self._rooms:
return []
return [b for b in self._rooms[room_id].bounties if b.deleted_at is None]
def list_all_bounties(
self, room_id: int, include_deleted: bool = True
) -> list[Bounty]:
if room_id not in self._rooms:
return []
if include_deleted:
return self._rooms[room_id].bounties
return [b for b in self._rooms[room_id].bounties if b.deleted_at is None]
class SimpleTrackingStorage:
"""Minimal mock without ensure_tracking - tests if track_bounty works without it.
@@ -119,6 +133,20 @@ class MockRoomStorage:
return b
return None
def list_bounties(self, room_id: int) -> list[Bounty]:
if room_id not in self._rooms:
return []
return [b for b in self._rooms[room_id].bounties if b.deleted_at is None]
def list_all_bounties(
self, room_id: int, include_deleted: bool = True
) -> list[Bounty]:
if room_id not in self._rooms:
return []
if include_deleted:
return self._rooms[room_id].bounties
return [b for b in self._rooms[room_id].bounties if b.deleted_at is None]
class MockTrackingStorage:
"""Mock implementation of TrackingStorage for testing."""

View File

@@ -15,18 +15,32 @@ class TestBountyService:
"""Set up fresh storage and service for each test."""
self.storage = MockRoomStorage()
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):
"""Test that add_bounty creates a new room if it doesn't exist."""
bounty = self.service.add_bounty(
room_id=-1001,
user_id=123,
user_id=self.admin_user_id,
text="Fix bug",
link="https://github.com/issue/1",
)
assert bounty.id == 1
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)
assert room is not None
@@ -34,14 +48,25 @@ class TestBountyService:
def test_add_bounty_increments_id(self):
"""Test that add_bounty increments bounty ID for each new bounty."""
b1 = self.service.add_bounty(room_id=-1001, user_id=123, text="First")
b2 = self.service.add_bounty(room_id=-1001, user_id=123, text="Second")
b3 = self.service.add_bounty(room_id=-1001, user_id=123, text="Third")
b1 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="First"
)
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 b2.id == 2
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):
"""Test list_bounties returns empty list for non-existent room."""
bounties = self.service.list_bounties(-1001)
@@ -49,9 +74,15 @@ class TestBountyService:
def test_list_bounties_returns_all_bounties(self):
"""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=123, text="Second")
self.service.add_bounty(room_id=-999, user_id=123, text="Other room")
self.service.add_bounty(room_id=-1001, user_id=self.admin_user_id, text="First")
self.service.add_bounty(
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)
assert len(bounties) == 2
@@ -59,7 +90,9 @@ class TestBountyService:
def test_get_bounty_found(self):
"""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)
assert found is not None
assert found.text == "Test"
@@ -71,31 +104,35 @@ class TestBountyService:
def test_get_bounty_wrong_room(self):
"""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
assert found is None
def test_update_bounty_success(self):
"""Test update_bounty succeeds when creator updates their bounty."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="Original")
"""Test update_bounty succeeds when admin updates their bounty."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Original"
)
result = self.service.update_bounty(
room_id=-1001,
bounty_id=bounty.id,
user_id=123,
user_id=self.admin_user_id,
text="Updated",
)
assert result is True
updated = self.service.get_bounty(-1001, bounty.id)
assert updated.text == "Updated"
def test_update_bounty_not_creator_raises_permission_error(self):
"""Test update_bounty raises PermissionError when non-creator tries to update."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="Original")
with pytest.raises(PermissionError, match="Only the creator can edit"):
def test_update_bounty_not_admin_raises_permission_error(self):
"""Test update_bounty raises PermissionError when non-admin tries to update."""
bounty = self.service.add_bounty(
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(
room_id=-1001,
bounty_id=bounty.id,
user_id=999, # different user
user_id=999, # different user, not admin
text="Hacked",
)
@@ -104,7 +141,7 @@ class TestBountyService:
result = self.service.update_bounty(
room_id=-1001,
bounty_id=999,
user_id=123,
user_id=self.admin_user_id,
text="Updated",
)
assert result is False
@@ -113,14 +150,14 @@ class TestBountyService:
"""Test update_bounty only updates provided fields."""
bounty = self.service.add_bounty(
room_id=-1001,
user_id=123,
user_id=self.admin_user_id,
text="Original",
link="https://original.link",
)
self.service.update_bounty(
room_id=-1001,
bounty_id=bounty.id,
user_id=123,
user_id=self.admin_user_id,
text="Updated only text",
)
updated = self.service.get_bounty(-1001, bounty.id)
@@ -131,37 +168,95 @@ class TestBountyService:
"""Test update_bounty can clear link."""
bounty = self.service.add_bounty(
room_id=-1001,
user_id=123,
user_id=self.admin_user_id,
text="Test",
link="https://original.link",
)
self.service.update_bounty(
room_id=-1001,
bounty_id=bounty.id,
user_id=123,
user_id=self.admin_user_id,
clear_link=True,
)
updated = self.service.get_bounty(-1001, bounty.id)
assert updated.link is None
def test_delete_bounty_success(self):
"""Test delete_bounty succeeds when creator deletes their bounty."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="To delete")
result = self.service.delete_bounty(-1001, bounty.id, 123)
"""Test delete_bounty soft deletes when admin deletes their bounty."""
bounty = self.service.add_bounty(
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
# Soft delete - bounty should not be found via get_bounty
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):
"""Test delete_bounty raises PermissionError when non-creator tries to delete."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="To delete")
with pytest.raises(PermissionError, match="Only the creator can delete"):
self.service.delete_bounty(-1001, bounty.id, 999) # different user
def test_delete_bounty_not_admin_raises_permission_error(self):
"""Test delete_bounty raises PermissionError when non-admin tries to delete."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="To delete"
)
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):
"""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
def test_delete_bounties_multiple_success(self):
"""Test delete_bounties soft deletes multiple bounties."""
b1 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="First"
)
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"
)
results = self.service.delete_bounties(
-1001, [b1.id, b2.id, b3.id], self.admin_user_id
)
assert results == {b1.id: "deleted", b2.id: "deleted", b3.id: "deleted"}
assert self.service.get_bounty(-1001, b1.id) is None
assert self.service.get_bounty(-1001, b2.id) is None
assert self.service.get_bounty(-1001, b3.id) is None
def test_delete_bounties_mixed_results(self):
"""Test delete_bounties returns individual results per ID."""
b1 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Exists"
)
results = self.service.delete_bounties(
-1001, [b1.id, 999, 888], self.admin_user_id
)
assert results == {b1.id: "deleted", 999: "not_found", 888: "not_found"}
def test_delete_bounties_permission_denied(self):
"""Test delete_bounties returns permission_denied for non-admin."""
b1 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="First"
)
b2 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Second"
)
results = self.service.delete_bounties(
-1001,
[b1.id, b2.id],
999, # non-admin user
)
assert results == {b1.id: "permission_denied", b2.id: "permission_denied"}
# Bounties should not be deleted
assert self.service.get_bounty(-1001, b1.id) is not None
assert self.service.get_bounty(-1001, b2.id) is not None
class TestTrackingService:
"""Unit tests for TrackingService."""
@@ -171,9 +266,27 @@ class TestTrackingService:
self.room_storage = MockRoomStorage()
self.tracking_storage = MockTrackingStorage()
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"):
"""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)
return bounty_service.add_bounty(room_id=room_id, user_id=user_id, text=text)