Compare commits

...

75 Commits

Author SHA1 Message Date
shokollm
3743dc6a45 fix: add sort and limit to /my command for consistency with /bounty
- Add same sort_key logic as /bounty (due date first, then created_at)
- Add default limit of 5 with "Showing X of Y" message
- Add "all" flag to show expired bounties
- Add delete button for consistency with /bounty

Fixes #94
2026-04-09 14:08:57 +00:00
7e0bc1f8a3 Merge pull request 'test: Category Feature - Tests (#88)' (#93) from feature/category-tests into main 2026-04-09 13:17:01 +02:00
shokollm
961adf103b test: add category feature tests
Tests for category feature (Issue #88):

Category Management Tests (11 tests):
- test_add_category_requires_admin
- test_add_category_duplicate_slug_fails
- test_add_category_invalid_slug_fails_uppercase
- test_add_category_invalid_slug_fails_with_numbers
- test_add_category_invalid_slug_fails_with_symbols
- test_add_category_invalid_slug_fails_empty
- test_add_category_valid
- test_delete_category_soft_deletes
- test_deleted_category_not_listed
- test_list_categories_empty
- test_list_categories_returns_active
- test_get_category_not_found
- test_get_category_deleted_returns_none
- test_add_category_requires_admin_non_existent_room

Category-to-Bounty Tests (10 tests):
- test_add_category_to_bounty
- test_add_duplicate_category_to_bounty_noop
- test_add_category_to_bounty_invalid_bounty
- test_add_category_to_bounty_invalid_category
- test_remove_category_from_bounty
- test_remove_category_not_on_bounty_returns_false
- test_update_bounty_categories_replace_all
- test_update_bounty_categories_clear_all
- test_update_bounty_categories_validates
- test_add_category_to_bounty_requires_admin
- test_remove_category_from_bounty_requires_admin
- test_update_bounty_categories_requires_admin
- test_delete_category_requires_admin

All 123 tests pass (96 original + 27 new)
2026-04-09 11:08:46 +00:00
e9d7ba0c8e Merge pull request 'feat: Category Feature - Commands (#87)' (#92) from feature/category-commands into main 2026-04-09 13:06:36 +02:00
shokollm
e43c36b84f feat: add category command handlers
Commands layer for category feature (Issue #87):

New Commands:
- /category - list all categories
- /category add <slug> <name> - create category (admin)
- /category delete <slug> - soft delete category (admin)

Updated Commands:
- /add - supports -cat <slug>[,<slug>] flag
- /update - supports -cat, -cat -, and -remove-cat flags
- /bounty - supports -c <slug>[,<slug>] filter
- /show - displays categories for bounty
- /help - includes category commands

Syntax Examples:
- /add Fix bug github.com/repo -cat bug,urgent
- /update 1 -cat feature,docs
- /update 1 -cat - (clear categories)
- /update 1 -remove-cat bug
- /bounty -c bug
- /bounty -c bug,feature
2026-04-09 10:50:50 +00:00
44680dcb4c Merge pull request 'feat: Category Feature - Service Layer (#86)' (#91) from feature/category-service into main 2026-04-09 12:44:18 +02:00
shokollm
7c17bff110 feat: add category service layer methods
Service layer for category feature (Issue #86):

Category Management:
- add_category() - Create category (admin only, validates slug format)
- delete_category() - Soft delete category (admin only)
- list_categories() - List active categories
- get_category() - Get category by slug

Category-to-Bounty Association:
- add_category_to_bounty() - Add category to bounty (admin only)
- remove_category_from_bounty() - Remove category from bounty (admin only)
- update_bounty_categories() - Replace all categories on bounty (admin only)

All methods properly validate permissions, slug format, and existence.
Soft delete preserves category data for bounties that reference it.
2026-04-09 10:38:08 +00:00
43d07eae92 Merge pull request 'feat: Category Feature - Models & Storage (#85)' (#90) from feature/category-models-storage into main 2026-04-09 12:35:50 +02:00
shokollm
66d2a9fb86 feat: add Category model and update storage for categories
Models & Storage layer for category feature (Issue #85):

- Add Category dataclass to core/models.py
  - id (slug): lowercase alphabetic only
  - name: display name
  - created_at: Unix timestamp
  - deleted_at: soft delete timestamp (None if active)

- Add category_ids field to Bounty dataclass
  - list[str] for multiple categories per bounty
  - Default empty list for backward compatibility

- Add categories field to RoomData dataclass
  - list[Category] for room-level categories
  - Default empty list

- Update JsonFileRoomStorage to serialize/deserialize:
  - Category fields (id, name, created_at, deleted_at)
  - Bounty.category_ids
  - RoomData.categories

Backward compatible: existing data without categories works fine.
2026-04-09 10:23:33 +00:00
shokollm
235a89653f test: add pytest.ini for easier test running
- Set testpaths = tests
- Set pythonpath = . (no need for PYTHONPATH=. prefix)
- Set asyncio_default_fixture_loop_scope = function
2026-04-09 10:15:27 +00:00
shokollm
2158f71fd0 feat: normalize URLs without scheme to https://
- Add normalize_url() helper function in commands.py
- Automatically prefix URLs without scheme (e.g. github.com → https://github.com)
- Applies to both -link flag and auto-detected URLs
- Add 5 new tests for URL normalization
- Fix existing tests to handle 5-value return from parse_args()

Examples:
  /add Fix bug github.com/user/repo
  → stored as: https://github.com/user/repo
2026-04-09 10:10:06 +00:00
shokollm
4885be0752 fix: cleanup codebase and sync SPEC with actual permissions
Phase 1: Ruff lint fixes
- Remove unused imports across all files
- Remove unused variables (now_utc, tz, ctx)
- Fix f-string without placeholders
- Fix E402 import order with noqa comments

Phase 2: Remove confusing hard delete from storage
- Removed delete_bounty() from RoomStorage Protocol (never used by app)
- Removed delete_bounty() from JsonFileRoomStorage (was hard delete)
- Removed corresponding tests (hard delete was never used)

Phase 3: Sync SPEC.md with actual code behavior
- Updated overview: admins can add/edit/delete (not 'anyone' + 'creator')
- Updated command table: /add, /edit, /delete are admin only
- Updated error handling messages

Test results: 96 passed (2 hard delete tests removed)
2026-04-09 10:01:02 +00:00
shokollm
75122b3ee2 docs: add audit and category spec from feynman 2026-04-09 09:30:42 +00:00
shokollm
8494b4621c feat: switch admin identification from user_id to username
- Replace admin_user_ids (list[int]) with admin_usernames (list[str])
- Update all service methods to use username for permission checks
- Add delete button to bot responses for message cleanup
- Update tests to match new implementation

Note: Breaking change - existing data files need fresh start
2026-04-09 08:02:36 +00:00
shokollm
7822e65d6c debug: add logging to _find_user_id_by_username
To see the actual error when user lookup fails.
2026-04-05 02:34:47 +00:00
shokollm
c57b422b6a fix: use Telegram API to lookup users by username
_find_user_id_by_username now uses ctx.bot.get_chat() to lookup
any user by username, not just bounty creators. This allows
/admin add to work for any user in the group.
2026-04-05 02:31:38 +00:00
shokollm
1db8e48414 feat: use HTML links for admin list
Now /admin shows usernames as clickable tg://user?id= links,
same format as /show command. Uses HTML parse mode.
2026-04-05 02:26:10 +00:00
shokollm
1bde18589c fix: unify date format in add and edit confirmations
Now uses format_due_date() for edit confirmation dates,
matching the add confirmation format: "5 April 2026 13:00 (Asia/Jakarta)"
2026-04-05 02:18:45 +00:00
shokollm
22c7d8f4f5 fix: define tz variable in cmd_update before using it
The cmd_update function was missing the tz = ZoneInfo(timezone_str) conversion.
This caused "tz is not defined" error when confirming edits.
2026-04-05 02:08:01 +00:00
shokollm
b85806f3ad fix: use room timezone in edit confirmation dates
The edit confirmation was using time.localtime() (server timezone).
Now uses datetime.fromtimestamp() with room's timezone.
2026-04-05 02:00:13 +00:00
shokollm
442e5279cc fix: properly detect flags after -link and -date
Previously, /edit 28 -link -date would treat -date as the link value.
Now it checks if the next argument starts with "-" to detect flags.
2026-04-05 01:57:56 +00:00
shokollm
6c99751827 fix: remove catch-all handler for unknown commands
Unknown commands like /asdasda no longer show help.
Only known commands (with explicit handlers) respond.
2026-04-05 01:49:58 +00:00
shokollm
e570acff4f fix: add created_by_username parameter to BountyService.add_bounty
The add_bounty method now accepts created_by_username parameter
to store the creator's username for display purposes.
2026-04-05 01:41:02 +00:00
shokollm
8cc7b09716 fix: only show user in /show if username is stored
Now only shows the creator line if created_by_username exists,
no fallback to "User {id}" which could be confusing.
2026-04-05 01:39:29 +00:00
shokollm
a1946e4c4e feat: store username when creating bounty for display
When adding a bounty, capture effective_user's username or first_name
and store it for display in /show. Now shows actual name instead of
just "User".
2026-04-05 01:36:30 +00:00
shokollm
db1369c004 feat: use HTML parse mode for user link in /show
Now using <a href="tg://user?id=XXX">User</a> with HTML parse mode
for clickable user link without pinging.
2026-04-05 01:28:07 +00:00
shokollm
17b022fc6c fix: show creator as clickable user link without pinging
Changed from @user#1663194938 to using tg://user?id=XXX link format.
This creates a clickable link to the user's DM without sending a ping.
2026-04-05 00:53:25 +00:00
shokollm
cecf71da5d fix: parse dates using group's timezone when adding/updating bounties
The parse_args function now accepts timezone_str parameter and uses it
to localize parsed dates. This ensures dates are interpreted in the
group's timezone, not the server's local timezone.
2026-04-05 00:25:59 +00:00
shokollm
58614fbda2 fix: use group timezone in bounty list and show command
1. format_bounty now accepts timezone_str parameter
2. Calculate hours/days remaining using group's timezone
3. Format dates using group's timezone (not server local time)
4. Updated cmd_bounty, cmd_my, cmd_show to pass timezone
2026-04-05 00:06:59 +00:00
shokollm
ec29e4f15f feat: add error handler to bot for better error logging
This will help catch and log errors that were previously
showing as "No error handlers are registered"
2026-04-04 23:59:07 +00:00
shokollm
858305ebac fix: show time in date display and bounty list
1. format_bounty now shows time (HH:MM) if time is set
2. Bounty list shows hours remaining if < 48 hours:
   - < 1 hour: shows minutes (e.g., 45m)
   - < 48 hours: shows hours (e.g., 6h)
   - >= 48 hours: shows days (e.g., 3d)
3. Update message now shows time in date display
2026-04-04 23:54:34 +00:00
shokollm
a76aab657f fix: properly parse time (HH:MM) after date
The parse_args function now:
1. Recognizes time format (HH:MM) after parsing a date
2. Combines date + time into a single timestamp
3. Only text comes before link or date flags

Now /update 6 -date 2026-04-05 12:00 properly sets date+time
2026-04-04 23:48:48 +00:00
shokollm
cfe5f019f2 fix: single delete button per message, use query.message.delete()
- /bounty now shows only ONE delete button (not per-bounty)
- Callback uses query.message.delete() instead of delete_message by ID
- This is more reliable and simpler
2026-04-04 23:45:19 +00:00
shokollm
7b64da7897 fix: -link with any value sets link regardless of URL format
Previously, /update 2 -link s would clear the link and then parse
's' as a date. Now, any value after -link is used as the link,
regardless of whether it looks like a URL.
2026-04-04 23:42:15 +00:00
shokollm
7c238b44c8 fix: accept domain-only URLs like google.com
The is_url() function now accepts any string with a dot and no spaces,
not just URLs with http:// or containing /. This allows /update 2 -link google.com
to properly set the link instead of treating google.com as text.
2026-04-04 23:35:22 +00:00
shokollm
7a4d938c41 fix: /edit command improvements
1. Accept any URL-like string as link (not just http/https)
   - Now detects URLs by pattern: contains "." and "/" (e.g., github.com/foo)

2. Show old -> new changes in update response
   - Now shows exactly what changed for verification
   - Helps user catch mistakes immediately
2026-04-04 23:29:03 +00:00
shokollm
dfafefe071 feat: add inline delete button to /bounty list
- Add inline keyboard with delete button on bounty list messages
- Only the user who triggered the command can delete the message
- Message is actually removed from the chat
- Uses callback query handler for button clicks
2026-04-04 23:25:20 +00:00
shokollm
d01d147a45 refactor: reorganize help command with admin/non-admin split
Non-admin sees minimal commands: bounty, track, untrack, my, show, start, help
Admin sees organized by category: Bounty Management, Tracking, Room Management
2026-04-04 23:11:41 +00:00
shokollm
a667ba216a refactor: simplify help command
- Show only top-level commands without variations
- Show admin-specific commands only to admins
- Reduces cognitive overhead for normal users
2026-04-04 23:07:58 +00:00
shokollm
badb2e3292 fix: handle admin_user_ids=None case in add_admin
When loading room data with admin_user_ids=null in JSON,
the code now properly initializes admin_user_ids to []
instead of incorrectly creating a new RoomData.

Also removed debug logging added during troubleshooting.
2026-04-04 22:59:37 +00:00
shokollm
6e5006b429 debug: add detailed logging to add_admin for troubleshooting 2026-04-04 16:08:46 +00:00
shokollm
cce71e55c2 debug: add logging to cmd_start for troubleshooting admin promotion
Adding logging to understand why group creator admin promotion
may not be working in production.
2026-04-04 16:01:24 +00:00
shokollm
8ac8cd21ec fix: allow self-promotion to first admin in room
Users can now add themselves as the first admin without existing
admin permission. This enables /start in DMs to work correctly.
2026-04-04 15:34:58 +00:00
shokollm
d75f897043 feat: auto-promote group creator AND DM user to admin
- Groups: group creator auto-promoted to admin via /start
- DMs: user automatically becomes admin of their own DM
2026-04-04 15:30:27 +00:00
shokollm
0260cae40b feat: auto-promote group creator to admin
When /start is called in a group, check if user is the group creator
and automatically add them as admin. DMs don't need admin concept.
2026-04-04 15:24:47 +00:00
shokollm
408318d323 feat: bot reads JIGAIDO_BOT_TOKEN from config file
- config.py: Added _resolve_bot_token() to read from config file
- bot.py: Uses config.config.bot_token instead of env var directly
- test_config.py: Added test for config file token reading
2026-04-04 15:16:55 +00:00
5502de96ad Merge pull request 'feat: implement /recover command and fix /admin list' (#83) from fix/issue-49-50-recover-admin-list into main 2026-04-04 16:42:07 +02:00
shokollm
6a933742cb feat: implement /recover command and fix /admin list
- Add /recover command for listing and recovering soft-deleted bounties
  - /recover - list recoverable bounties (admin only)
  - /recover <id> [<id>...] - recover specific bounties (admin only)
- Fix /admin list to show @username instead of admin_id
- Add recover_bounty and recover_bounties methods to BountyService
- Add get_deleted_bounty method to BountyService
- Clean up duplicate cmd_admin functions
- Add /recover to bot command menu
- Fixes #49 and #50
2026-04-04 14:29:19 +00:00
b554a81979 Merge pull request 'feat: human-readable date format with timezone awareness (#54)' (#80) from fix/issue-54-v2 into main 2026-04-04 15:37:20 +02:00
shokollm
350ecbf867 feat: human-readable date format with timezone awareness
Add format_due_date() for human-readable dates like "4 April 2026".
Update cmd_add to use timezone-aware date formatting.

Fixes #54
2026-04-04 20:36:38 +07:00
28241eaf61 Merge pull request 'feat(/admin): add /admin command for admin management' (#78) from fix/issue-52 into main 2026-04-04 15:32:44 +02:00
shokollm
8db5ba0ba4 Merge fix/issue-52 with conflict resolution 2026-04-04 20:32:06 +07:00
a727751978 Merge pull request 'feat: add multi-ID delete support with per-ID results' (#79) from fix/issue-47 into main 2026-04-04 15:28:36 +02:00
shokollm
90b0b564c2 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 3 tests for delete_bounties method

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

Fixes #47
2026-04-04 13:06:56 +00:00
003c570cfb Merge pull request 'feat(/add): add admin-only and link uniqueness handling' (#77) from fix/issue-45-v3 into main 2026-04-04 12:59:48 +02:00
shokollm
bac6830fc3 feat(/add): add admin-only and link uniqueness handling
Wrap add_bounty call in try-except to handle PermissionError
and ValueError from admin check and link uniqueness check.

Fixes #45
2026-04-04 17:59:20 +07:00
2dd11a8b48 Merge pull request 'feat: remove "by user" from bounty list display' (#76) from fix/issue-55 into main 2026-04-04 12:53:36 +02:00
shokollm
2617d17e28 feat: remove "by user" from bounty list display
Removes created_by_user_id from format_bounty() output.
Fixes #55
2026-04-04 17:52:47 +07:00
b091153f10 Merge pull request 'feat: implement /admin add|remove @username command' (#75) from fix/issue-51-v2 into main 2026-04-04 12:18:31 +02:00
shokollm
ce864d9fdc feat: implement /admin add|remove @username command
- Add cmd_admin handler for /admin add|remove @username
- Add _find_user_id_by_username helper to resolve usernames from bounty creators
- Register admin command handler in bot.py
- Add 'admin' to bot command list
- Addresses issue #51
2026-04-04 08:20:35 +00:00
e805a6428a Merge pull request 'feat: implement /timezone command to get/set room timezone' (#72) from fix/issue-53 into main 2026-04-04 10:15:42 +02:00
shokollm
6da16e752b feat: implement /timezone command to get/set room timezone
Re-implement the timezone command that was reverted.

- Add cmd_timezone function with get/set functionality
- Validate timezone using zoneinfo (IANA format)
- Admin-only permission via service layer
- Update help text and bot command list
- Fix indentation bug in cmd_add (duplicate lines)

Fixes #53
2026-04-04 08:14:58 +00:00
e3b813661d Merge pull request 'feat(/bounty): add pagination, sorting, and filtering' (#62) from fix/issue-48-bounty-pagination into main 2026-04-04 10:09:29 +02:00
bdb0f3cd8b Merge pull request 'feat: implement /show command to display full bounty details' (#59) from fix/issue-44 into main 2026-04-04 10:09:28 +02:00
shokollm
649b1ffbd3 revert: remove timezone command and revert date format to simple YYYY-MM-DD
This reverts:
- cmd_timezone function (issue #67)
- format_due_date with human-readable dates (issue #68)
- Reverts date display back to time.strftime("%Y-%m-%d")
- Keeps /edit command with -link/-date flags (issue #46)
2026-04-04 15:05:29 +07:00
shokollm
b8f6b98836 Merge pull request #61 from fix/issue-46 2026-04-04 07:40:59 +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
a06e1327fb feat(/edit): per-argument updates + clear syntax + admin-only
- Add -link and -date flags to /edit command for field clearing
- /edit <id> -link - clear link
- /edit <id> -date - clear date
- /edit <id> -link <url> - set link
- /edit <id> -date <date> - set date
- /edit <id> text -link - update text, clear link
- /edit <id> text <url> - update text and set link
- Parse_args now returns (text, link, due_date_ts, clear_link, clear_date)
- Update usage messages and help text
- Fixes #46
2026-04-04 05:51:56 +00:00
shokollm
780cba6301 feat: implement /show command to display full bounty details
- Add cmd_show function to display bounty details including:
  - ID and full text (not sliced)
  - Link if exists
  - Due date formatted with room timezone
  - Created by username
  - Created at timestamp
- Register show command handler in bot.py
- Add show command to help text and bot command list
- Fixes #44
2026-04-04 05:43:50 +00:00
20 changed files with 2701 additions and 317 deletions

10
SPEC.md
View File

@@ -8,7 +8,7 @@
JIGAIDO is a Telegram bot that lets groups and individuals track bounties — tasks, obligations, and deadlines — with optional due dates and personal tracking. JIGAIDO is a Telegram bot that lets groups and individuals track bounties — tasks, obligations, and deadlines — with optional due dates and personal tracking.
- **Group mode**: Each Telegram group has its own bounty list. Anyone can add bounties. Only creator can edit/delete. - **Group mode**: Each Telegram group has its own bounty list. Admins can add/edit/delete bounties. Anyone can track.
- **DM mode**: Personal bounty list. Anyone can manage their own bounties. - **DM mode**: Personal bounty list. Anyone can manage their own bounties.
- **Tracking**: Users can track any bounty (group or personal) to their tracking list. - **Tracking**: Users can track any bounty (group or personal) to their tracking list.
- **Due dates**: Free-form text (`"april 15"`, `"in 3 days"`, `"tomorrow"`) parsed at add time, stored as Unix timestamp. If unparseable, stored as `NULL`. - **Due dates**: Free-form text (`"april 15"`, `"in 3 days"`, `"tomorrow"`) parsed at add time, stored as Unix timestamp. If unparseable, stored as `NULL`.
@@ -122,9 +122,9 @@ Data is stored at `~/.jigaido/` (home directory), NOT inside the repository.
|---|---|---| |---|---|---|
| `/bounty` | anyone | List all bounties in this group | | `/bounty` | anyone | List all bounties in this group |
| `/my` | anyone | List bounties tracked by you 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 | | `/add <text> [link] [due date]` | admin only | Add a new bounty to the group |
| `/edit <bounty_id> [text] [link] [due_date]` | creator only | Edit an existing bounty | | `/edit <bounty_id> [text] [link] [due_date]` | admin only | Edit an existing bounty |
| `/delete <bounty_id>` | creator only | Delete a bounty | | `/delete <bounty_id>` | admin only | Delete a bounty |
| `/track <bounty_id>` | anyone | Track a group bounty | | `/track <bounty_id>` | anyone | Track a group bounty |
| `/untrack <bounty_id>` | anyone | Stop tracking a bounty | | `/untrack <bounty_id>` | anyone | Stop tracking a bounty |
@@ -170,7 +170,7 @@ Stored as Unix timestamp. User-facing display can be localized/converted to any
## Error Handling ## Error Handling
- Unknown command → help text with available commands - Unknown command → help text with available commands
- `/edit`/`/delete` by non-creator → "⛔ Only the creator can edit/delete this bounty." - `/add`/`/edit`/`/delete` by non-admin → "⛔ Only admins can add/edit/delete bounties."
- `/track` already tracked → "Already tracking" (idempotent) - `/track` already tracked → "Already tracking" (idempotent)
- `/untrack` not tracked → "Not tracking" (idempotent) - `/untrack` not tracked → "Not tracking" (idempotent)
- Bounty not found → "Bounty not found" - Bounty not found → "Bounty not found"

View File

@@ -11,8 +11,7 @@ import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from core.models import Bounty, RoomData, TrackingData, TrackedBounty from core.models import Bounty, Category, RoomData, TrackingData, TrackedBounty
from core.ports import RoomStorage, TrackingStorage
class JsonFileRoomStorage: class JsonFileRoomStorage:
@@ -58,16 +57,28 @@ class JsonFileRoomStorage:
created_by_user_id=b["created_by_user_id"], created_by_user_id=b["created_by_user_id"],
deleted_at=b.get("deleted_at"), deleted_at=b.get("deleted_at"),
created_by_username=b.get("created_by_username"), created_by_username=b.get("created_by_username"),
category_ids=b.get("category_ids", []),
) )
for b in data.get("bounties", []) for b in data.get("bounties", [])
] ]
categories = [
Category(
id=c["id"],
name=c["name"],
created_at=c["created_at"],
deleted_at=c.get("deleted_at"),
)
for c in data.get("categories", [])
]
return RoomData( return RoomData(
room_id=data["room_id"], room_id=data["room_id"],
bounties=bounties, bounties=bounties,
next_id=data["next_id"], next_id=data["next_id"],
timezone=data.get("timezone"), timezone=data.get("timezone"),
admin_user_ids=data.get("admin_user_ids", []), admin_usernames=data.get("admin_usernames", []),
categories=categories,
) )
def save(self, room_data: RoomData) -> None: def save(self, room_data: RoomData) -> None:
@@ -76,7 +87,16 @@ class JsonFileRoomStorage:
"room_id": room_data.room_id, "room_id": room_data.room_id,
"next_id": room_data.next_id, "next_id": room_data.next_id,
"timezone": room_data.timezone, "timezone": room_data.timezone,
"admin_user_ids": room_data.admin_user_ids or [], "admin_usernames": room_data.admin_usernames or [],
"categories": [
{
"id": c.id,
"name": c.name,
"created_at": c.created_at,
"deleted_at": c.deleted_at,
}
for c in room_data.categories
],
"bounties": [ "bounties": [
{ {
"id": b.id, "id": b.id,
@@ -87,6 +107,7 @@ class JsonFileRoomStorage:
"created_by_user_id": b.created_by_user_id, "created_by_user_id": b.created_by_user_id,
"deleted_at": b.deleted_at, "deleted_at": b.deleted_at,
"created_by_username": b.created_by_username, "created_by_username": b.created_by_username,
"category_ids": b.category_ids,
} }
for b in room_data.bounties for b in room_data.bounties
], ],
@@ -119,15 +140,6 @@ class JsonFileRoomStorage:
self.save(room_data) self.save(room_data)
def delete_bounty(self, room_id: int, bounty_id: int) -> None:
"""Delete a bounty from a room."""
room_data = self.load(room_id)
if room_data is None:
return
room_data.bounties = [b for b in room_data.bounties if b.id != bounty_id]
self.save(room_data)
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 from a room by ID.""" """Get a specific bounty from a room by ID."""
room_data = self.load(room_id) room_data = self.load(room_id)

View File

@@ -1,19 +1,30 @@
"""JIGAIDO Telegram bot entrypoint.""" """JIGAIDO Telegram bot entrypoint."""
import logging import logging
import os
import sys import sys
from telegram.ext import Application, CommandHandler, MessageHandler, filters sys.path.insert(0, "/home/shoko/repositories/jigaido")
from telegram.ext import (
Application,
CommandHandler,
CallbackQueryHandler,
)
from commands import ( from commands import (
cmd_add, cmd_add,
cmd_admin,
cmd_bounty, cmd_bounty,
cmd_category,
cmd_delete, cmd_delete,
cmd_delete_message,
cmd_edit, cmd_edit,
cmd_help, cmd_help,
cmd_my, cmd_my,
cmd_recover,
cmd_show,
cmd_start, cmd_start,
cmd_timezone,
cmd_track, cmd_track,
cmd_untrack, cmd_untrack,
cmd_update, cmd_update,
@@ -25,7 +36,13 @@ logging.basicConfig(
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BOT_TOKEN = os.environ.get("JIGAIDO_BOT_TOKEN", "") from config import config # noqa: E402
BOT_TOKEN = config.bot_token or ""
async def error_handler(update, context):
log.error(f"Error: {context.error}")
def build_app() -> Application: def build_app() -> Application:
@@ -41,8 +58,15 @@ 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("show", cmd_show))
app.add_handler(CommandHandler("timezone", cmd_timezone))
app.add_handler(CommandHandler("admin", cmd_admin))
app.add_handler(CommandHandler("recover", cmd_recover))
app.add_handler(CommandHandler("category", cmd_category))
app.add_handler(MessageHandler(filters.COMMAND, cmd_help)) app.add_handler(CallbackQueryHandler(cmd_delete_message))
app.add_error_handler(error_handler)
return app return app
@@ -56,12 +80,19 @@ 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"),
("show", "Show bounty details"),
("timezone", "Get/set room timezone"),
("admin", "Manage admins"),
("recover", "Recover deleted bounties"),
("category", "Manage categories"),
("help", "Show help"), ("help", "Show help"),
] ]
) )
def main() -> None: def main() -> None:
import asyncio
if not BOT_TOKEN: if not BOT_TOKEN:
log.error("JIGAIDO_BOT_TOKEN environment variable not set.") log.error("JIGAIDO_BOT_TOKEN environment variable not set.")
sys.exit(1) sys.exit(1)
@@ -70,6 +101,11 @@ def main() -> None:
app.post_init = post_init app.post_init = post_init
log.info("JIGAIDO starting...") log.info("JIGAIDO starting...")
# Python 3.14 compatibility: ensure event loop exists
try:
asyncio.get_event_loop()
except RuntimeError:
asyncio.set_event_loop(asyncio.new_event_loop())
app.run_polling(drop_pending_updates=True) app.run_polling(drop_pending_updates=True)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
import asyncio
import os
import sys
# Run from the telegram-bot directory so local imports work
os.chdir("/home/shoko/repositories/jigaido/apps/telegram-bot")
sys.path.insert(0, "/home/shoko/repositories/jigaido")
# Import main from the local bot module
import bot as bot_module # noqa: E402
if __name__ == "__main__":
if not bot_module.BOT_TOKEN:
bot_module.log.error("JIGAIDO_BOT_TOKEN environment variable not set.")
sys.exit(1)
app = bot_module.build_app()
app.post_init = bot_module.post_init
bot_module.log.info("JIGAIDO starting...")
# PTB v20+ app.run_polling() is async - use asyncio.get_event_loop() + run_until_complete
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.run_polling(drop_pending_updates=True))
finally:
loop.close()

View File

@@ -1,10 +1,8 @@
"""Pytest fixtures for telegram-bot tests.""" """Pytest fixtures for telegram-bot tests."""
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
import pytest
# Add the app directory to path so imports work when running pytest # Add the app directory to path so imports work when running pytest
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))

View File

@@ -1,11 +1,11 @@
"""Tests for commands.py — parsing, formatting, and command handlers.""" """Tests for commands.py — parsing, formatting, and command handlers."""
import time import time
from unittest.mock import MagicMock, patch, AsyncMock, sentinel from unittest.mock import MagicMock, patch, AsyncMock
import pytest import pytest
from telegram import Update, Message, User, Chat, CallbackQuery from telegram import Update, Message, User, Chat
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from commands import ( from commands import (
@@ -52,75 +52,97 @@ class TestExtractArgs:
class TestParseArgs: class TestParseArgs:
def test_text_only(self): def test_text_only(self):
text, link, due = parse_args(["hello", "world"]) text, link, due, _, _ = parse_args(["hello", "world"])
assert text == "hello world" assert text == "hello world"
assert link is None assert link is None
assert due is None assert due is None
def test_link_extracted(self): def test_link_extracted(self):
text, link, due = parse_args(["hello", "https://example.com"]) text, link, due, _, _ = parse_args(["hello", "https://example.com"])
# "hello" is non-link non-date → becomes text; only the URL becomes link # "hello" is non-link non-date → becomes text; only the URL becomes link
assert text == "hello" assert text == "hello"
assert link == "https://example.com" assert link == "https://example.com"
assert due is None assert due is None
def test_text_and_link(self): def test_text_and_link(self):
text, link, due = parse_args(["hello", "world", "https://example.com"]) text, link, due, _, _ = parse_args(["hello", "world", "https://example.com"])
assert text == "hello world" assert text == "hello world"
assert link == "https://example.com" assert link == "https://example.com"
def test_due_date_parsed(self): def test_due_date_parsed(self):
text, link, due = parse_args(["hello", "tomorrow"]) text, link, due, _, _ = parse_args(["hello", "tomorrow"])
assert text == "hello" assert text == "hello"
assert due is not None assert due is not None
# Should be some time in the future # Should be some time in the future
assert due > int(time.time()) assert due > int(time.time())
def test_all_three(self): def test_all_three(self):
text, link, due = parse_args(["hello", "https://example.com", "tomorrow"]) text, link, due, _, _ = parse_args(["hello", "https://example.com", "tomorrow"])
assert text == "hello" assert text == "hello"
assert link == "https://example.com" assert link == "https://example.com"
assert due is not None assert due is not None
def test_http_and_https_both_detected(self): def test_http_and_https_both_detected(self):
_, link1, _ = parse_args(["http://example.com"]) _, link1, _, _, _ = parse_args(["http://example.com"])
_, link2, _ = parse_args(["https://example.com"]) _, link2, _, _, _ = parse_args(["https://example.com"])
assert link1 == "http://example.com" assert link1 == "http://example.com"
assert link2 == "https://example.com" assert link2 == "https://example.com"
def test_non_url_non_date_becomes_text(self): def test_non_url_non_date_becomes_text(self):
text, link, due = parse_args(["fix", "the", "bug"]) text, link, due, _, _ = parse_args(["fix", "the", "bug"])
assert text == "fix the bug" assert text == "fix the bug"
assert link is None assert link is None
assert due is None assert due is None
def test_multiple_links_first_only(self): def test_multiple_links_first_only(self):
_, link, _ = parse_args(["text", "https://first.com", "https://second.com"]) _, link, _, _, _ = parse_args(["text", "https://first.com", "https://second.com"])
assert link == "https://first.com" assert link == "https://first.com"
def test_due_date_after_link(self): def test_due_date_after_link(self):
text, link, due = parse_args(["task", "https://example.com", "in 5 days"]) text, link, due, _, _ = parse_args(["task", "https://example.com", "in 5 days"])
assert text == "task" assert text == "task"
assert link == "https://example.com" assert link == "https://example.com"
assert due is not None assert due is not None
def test_empty_args(self): def test_empty_args(self):
text, link, due = parse_args([]) text, link, due, _, _ = parse_args([])
assert text is None assert text is None
assert link is None assert link is None
assert due is None assert due is None
def test_date_parser_failure_returns_none(self): def test_date_parser_failure_returns_none(self):
# "asdfjkl" is not parseable → goes to text # "asdfjkl" is not parseable → goes to text
text, link, due = parse_args(["hello", "asdfjkl"]) text, link, due, _, _ = parse_args(["hello", "asdfjkl"])
assert text == "hello asdfjkl" assert text == "hello asdfjkl"
assert due is None assert due is None
def test_link_takes_first_match(self): def test_link_takes_first_match(self):
# Even if it's not a valid URL, starts with https:// # Even if it's not a valid URL, starts with https://
_, link, _ = parse_args(["skip", "https://not-real.but-still-a-link"]) _, link, _, _, _ = parse_args(["skip", "https://not-real.but-still-a-link"])
assert link == "https://not-real.but-still-a-link" assert link == "https://not-real.but-still-a-link"
def test_url_without_scheme_normalized_to_https(self):
"""URLs without scheme should get https:// prefix."""
_, link, _, _, _ = parse_args(["github.com/user/repo"])
assert link == "https://github.com/user/repo"
def test_url_without_scheme_github_normalized(self):
text, link, _, _, _ = parse_args(["Fix bug", "github.com/owner/repo"])
assert text == "Fix bug"
assert link == "https://github.com/owner/repo"
def test_url_with_explicit_https_unchanged(self):
_, link, _, _, _ = parse_args(["task", "https://example.com/page"])
assert link == "https://example.com/page"
def test_url_with_http_unchanged(self):
_, link, _, _, _ = parse_args(["task", "http://example.com/page"])
assert link == "http://example.com/page"
def test_url_link_flag_without_scheme_normalized(self):
_, link, _, _, _ = parse_args(["-link", "example.com/path"])
assert link == "https://example.com/path"
class TestFormatBounty: class TestFormatBounty:
def _row( def _row(
@@ -368,7 +390,6 @@ class TestCmdAdd:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_needs_text_or_link(self): async def test_add_needs_text_or_link(self):
update = create_mock_update(message_text="/add") update = create_mock_update(message_text="/add")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
_, link, _ = parse_args([]) _, link, _ = parse_args([])
if not "test" and not link: if not "test" and not link:

View File

@@ -2,7 +2,6 @@
import argparse import argparse
import sys import sys
from pathlib import Path
import dateparser import dateparser

View File

@@ -13,7 +13,21 @@ class Config:
def __init__(self): def __init__(self):
self.data_dir: Path = self._resolve_data_dir() self.data_dir: Path = self._resolve_data_dir()
self.bot_token: Optional[str] = os.environ.get("JIGAIDO_BOT_TOKEN") self.bot_token: Optional[str] = self._resolve_bot_token()
def _resolve_bot_token(self) -> Optional[str]:
env_token = os.environ.get("JIGAIDO_BOT_TOKEN")
if env_token:
return env_token
config_file = Path("~/.jigaido/config.json").expanduser()
if config_file.exists():
with open(config_file) as f:
config_data = json.load(f)
if "JIGAIDO_BOT_TOKEN" in config_data:
return config_data["JIGAIDO_BOT_TOKEN"]
return None
def _resolve_data_dir(self) -> Path: def _resolve_data_dir(self) -> Path:
env_dir = os.environ.get("JIGAIDO_DATA_DIR") env_dir = os.environ.get("JIGAIDO_DATA_DIR")
@@ -35,3 +49,6 @@ class Config:
config = Config() config = Config()
config = Config()

View File

@@ -1,6 +1,20 @@
"""Domain dataclasses for JIGAIDO bounty tracker.""" """Domain dataclasses for JIGAIDO bounty tracker."""
from dataclasses import dataclass from dataclasses import dataclass, field
@dataclass
class Category:
"""A category for organizing bounties in a room.
Categories are per-room and support soft delete.
The id (slug) must be lowercase alphabetic only (e.g., "bug", "feature").
"""
id: str # slug: lowercase alphabetic only, e.g., "bug", "feature"
name: str # display name: e.g., "Bug", "Feature"
created_at: int
deleted_at: int | None = None # soft delete
@dataclass @dataclass
@@ -12,6 +26,8 @@ class Bounty:
The deleted_at field indicates soft-delete: None means not deleted, The deleted_at field indicates soft-delete: None means not deleted,
a value means deleted at that Unix timestamp. a value means deleted at that Unix timestamp.
The category_ids field lists category slugs associated with this bounty.
""" """
id: int id: int
@@ -22,6 +38,7 @@ class Bounty:
created_by_user_id: int created_by_user_id: int
deleted_at: int | None = None deleted_at: int | None = None
created_by_username: str | None = None created_by_username: str | None = None
category_ids: list[str] = field(default_factory=list)
@dataclass @dataclass
@@ -44,18 +61,22 @@ class RoomData:
The next_id field is used to generate unique bounty IDs within this room. 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 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. The admin_usernames field lists usernames who have admin privileges in this room.
The categories field contains all categories for organizing bounties in this room.
""" """
room_id: int room_id: int
bounties: list[Bounty] bounties: list[Bounty]
next_id: int next_id: int
timezone: str | None = None timezone: str | None = None
admin_user_ids: list[int] | None = None admin_usernames: list[str] | None = None
categories: list[Category] = field(default_factory=list)
def __post_init__(self): def __post_init__(self):
if self.admin_user_ids is None: if self.admin_usernames is None:
self.admin_user_ids = [] self.admin_usernames = []
if self.categories is None:
self.categories = []
@dataclass @dataclass

View File

@@ -32,10 +32,6 @@ class RoomStorage(Protocol):
"""Update an existing bounty in a room.""" """Update an existing bounty in a room."""
... ...
def delete_bounty(self, room_id: int, bounty_id: int) -> None:
"""Delete a bounty from a room."""
...
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 from a room by ID.""" """Get a specific bounty from a room by ID."""
... ...

View File

@@ -3,7 +3,7 @@
import time import time
from typing import Optional from typing import Optional
from core.models import Bounty, RoomData, TrackedBounty, TrackingData from core.models import Bounty, Category, RoomData, TrackedBounty, TrackingData
from core.ports import RoomStorage, TrackingStorage from core.ports import RoomStorage, TrackingStorage
@@ -25,63 +25,79 @@ class BountyService:
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: def is_admin(self, room_id: int, username: str | None) -> bool:
"""Check if user is admin in a room.""" """Check if user is admin in a room by username."""
if not username:
return False
room_data = self._storage.load(room_id) room_data = self._storage.load(room_id)
if room_data is None: if room_data is None:
return False return False
return user_id in (room_data.admin_user_ids or []) return username in (room_data.admin_usernames or [])
def add_admin( def add_admin(
self, room_id: int, admin_user_id: int, requesting_user_id: int self, room_id: int, username: str, requesting_username: str | None
) -> None: ) -> None:
"""Add an admin to a room. Requires admin permission.""" """Add an admin to a room. Requires admin permission, or self-promotion if first admin."""
if not self.is_admin(room_id, requesting_user_id):
raise PermissionError("Only admins can add admins.")
room_data = self._storage.load(room_id) room_data = self._storage.load(room_id)
has_no_admins = room_data is None or not room_data.admin_usernames
is_self_promotion = requesting_username == username
if not self.is_admin(room_id, requesting_username):
if not (has_no_admins and is_self_promotion):
raise PermissionError("Only admins can add admins.")
if room_data is None: if room_data is None:
room_data = RoomData( room_data = RoomData(
room_id=room_id, bounties=[], next_id=1, admin_user_ids=[] room_id=room_id, bounties=[], next_id=1, admin_usernames=[]
) )
if admin_user_id not in (room_data.admin_user_ids or []): admin_usernames = room_data.admin_usernames
(room_data.admin_user_ids or []).append(admin_user_id) if admin_usernames is None:
self._storage.save(room_data) admin_usernames = []
room_data.admin_usernames = []
if username in admin_usernames:
raise ValueError(f"@{username} is already an admin.")
admin_usernames.append(username)
self._storage.save(room_data)
def remove_admin( def remove_admin(
self, room_id: int, admin_user_id: int, requesting_user_id: int self, room_id: int, username: str, requesting_username: str | None
) -> None: ) -> None:
"""Remove an admin from a room. Requires admin permission.""" """Remove an admin from a room. Requires admin permission."""
if not self.is_admin(room_id, requesting_user_id): if not self.is_admin(room_id, requesting_username):
raise PermissionError("Only admins can remove admins.") raise PermissionError("Only admins can remove admins.")
room_data = self._storage.load(room_id) room_data = self._storage.load(room_id)
if room_data is None: if room_data is None or not (room_data.admin_usernames or []):
return raise ValueError(f"@{username} is not an admin.")
if admin_user_id in (room_data.admin_user_ids or []): if username not in (room_data.admin_usernames or []):
(room_data.admin_user_ids or []).remove(admin_user_id) raise ValueError(f"@{username} is not an admin.")
self._storage.save(room_data)
def list_admins(self, room_id: int) -> list[int]: (room_data.admin_usernames or []).remove(username)
"""List all admin user IDs in a room.""" self._storage.save(room_data)
def list_admins(self, room_id: int) -> list[str]:
"""List all admin usernames in a room."""
room_data = self._storage.load(room_id) room_data = self._storage.load(room_id)
if room_data is None: if room_data is None:
return [] return []
return list(room_data.admin_user_ids or []) return list(room_data.admin_usernames or [])
def set_timezone( def set_timezone(
self, room_id: int, timezone: str, requesting_user_id: int self, room_id: int, timezone: str, requesting_username: str | None
) -> None: ) -> None:
"""Set the timezone for a room. Requires admin permission.""" """Set the timezone for a room. Requires admin permission."""
if not self.is_admin(room_id, requesting_user_id): if not self.is_admin(room_id, requesting_username):
raise PermissionError("Only admins can set timezone.") raise PermissionError("Only admins can set timezone.")
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_data = RoomData(
room_id=room_id, bounties=[], next_id=1, admin_user_ids=[] room_id=room_id, bounties=[], next_id=1, admin_usernames=[]
) )
room_data.timezone = timezone room_data.timezone = timezone
@@ -116,12 +132,14 @@ class BountyService:
self, self,
room_id: int, room_id: int,
user_id: int, user_id: int,
username: str | None,
text: Optional[str] = None, text: Optional[str] = None,
link: Optional[str] = None, link: Optional[str] = None,
due_date_ts: Optional[int] = None, due_date_ts: Optional[int] = None,
created_by_username: Optional[str] = None,
) -> Bounty: ) -> Bounty:
"""Add a new bounty to the room. Requires admin permission.""" """Add a new bounty to the room. Requires admin permission."""
if not self.is_admin(room_id, user_id): if not self.is_admin(room_id, username):
raise PermissionError("Only admins can add bounties.") raise PermissionError("Only admins can add bounties.")
if not self.check_link_unique(room_id, link): if not self.check_link_unique(room_id, link):
@@ -136,6 +154,7 @@ class BountyService:
bounty = Bounty( bounty = Bounty(
id=room_data.next_id, id=room_data.next_id,
created_by_user_id=user_id, created_by_user_id=user_id,
created_by_username=created_by_username,
text=text, text=text,
link=link, link=link,
due_date_ts=due_date_ts, due_date_ts=due_date_ts,
@@ -153,6 +172,44 @@ class BountyService:
all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True) 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] return [b for b in all_bounties if b.deleted_at is not None]
def get_deleted_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
"""Get a specific soft-deleted bounty by ID."""
all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True)
for b in all_bounties:
if b.id == bounty_id and b.deleted_at is not None:
return b
return None
def recover_bounty(self, room_id: int, bounty_id: int, username: str | None) -> str:
"""Recover a soft-deleted bounty. Admin only.
Returns: 'recovered', 'not_found', 'not_deleted', 'permission_denied'
"""
if not self.is_admin(room_id, username):
return "permission_denied"
bounty = self.get_deleted_bounty(room_id, bounty_id)
if not bounty:
return "not_found"
if bounty.deleted_at is None:
return "not_deleted"
bounty.deleted_at = None
self._storage.update_bounty(room_id, bounty)
return "recovered"
def recover_bounties(
self, room_id: int, bounty_ids: list[int], username: str | None
) -> dict[int, str]:
"""Recover multiple soft-deleted bounties. Admin only.
Returns dict of bounty_id -> result ('recovered', 'not_found', 'not_deleted', 'permission_denied')
"""
results = {}
for bounty_id in bounty_ids:
results[bounty_id] = self.recover_bounty(room_id, bounty_id, username)
return results
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. Excludes soft-deleted bounties.""" """Get a specific bounty by ID. Excludes soft-deleted bounties."""
bounty = self._storage.get_bounty(room_id, bounty_id) bounty = self._storage.get_bounty(room_id, bounty_id)
@@ -164,7 +221,7 @@ class BountyService:
self, self,
room_id: int, room_id: int,
bounty_id: int, bounty_id: int,
user_id: int, username: str | None,
text: Optional[str] = None, text: Optional[str] = None,
link: Optional[str] = None, link: Optional[str] = None,
due_date_ts: Optional[int] = None, due_date_ts: Optional[int] = None,
@@ -175,7 +232,7 @@ class BountyService:
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 not self.is_admin(room_id, user_id): if not self.is_admin(room_id, username):
raise PermissionError("Only admins can edit bounties.") raise PermissionError("Only admins can edit bounties.")
if link and not self.check_link_unique( if link and not self.check_link_unique(
@@ -198,18 +255,268 @@ class BountyService:
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, username: str | None) -> bool:
"""Soft delete a bounty. Only admins 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 not self.is_admin(room_id, user_id): if not self.is_admin(room_id, username):
raise PermissionError("Only admins can delete bounties.") raise PermissionError("Only admins can delete bounties.")
bounty.deleted_at = int(time.time()) bounty.deleted_at = int(time.time())
self._storage.update_bounty(room_id, bounty) self._storage.update_bounty(room_id, bounty)
return True return True
def delete_bounties(
self, room_id: int, bounty_ids: list[int], username: str | None
) -> dict[int, str]:
"""Soft delete multiple bounties. Returns dict of bounty_id -> result.
Results can be: 'deleted', 'not_found', 'permission_denied'
"""
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, username):
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
# --- Category Management ---
def add_category(
self,
room_id: int,
slug: str,
name: str,
username: str | None,
) -> Category:
"""Create a new category. Admin only.
Args:
room_id: Room identifier
slug: Category ID (lowercase alphabetic, e.g., "bug")
name: Display name (e.g., "Bug Report")
username: Requesting admin's username
Returns:
Created Category
Raises:
PermissionError: If not admin
ValueError: If slug already exists or invalid
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can add categories.")
# Validate slug format (lowercase alphabetic only)
if not slug or not slug.isalpha() or not slug.islower():
raise ValueError(
"Category slug must be lowercase alphabetic only (e.g., 'bug', 'feature')."
)
room_data = self._storage.load(room_id)
if room_data is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=1, admin_usernames=[], categories=[]
)
# Check for duplicate slug
for cat in room_data.categories:
if cat.id == slug and cat.deleted_at is None:
raise ValueError(f"Category '{slug}' already exists.")
category = Category(
id=slug,
name=name,
created_at=int(time.time()),
deleted_at=None,
)
room_data.categories.append(category)
self._storage.save(room_data)
return category
def delete_category(
self,
room_id: int,
slug: str,
username: str | None,
) -> bool:
"""Soft delete a category. Admin only.
Args:
room_id: Room identifier
slug: Category slug to delete
username: Requesting admin's username
Returns:
True if deleted, False if not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can delete categories.")
room_data = self._storage.load(room_id)
if room_data is None:
return False
for cat in room_data.categories:
if cat.id == slug and cat.deleted_at is None:
cat.deleted_at = int(time.time())
self._storage.save(room_data)
return True
return False
def list_categories(self, room_id: int) -> list[Category]:
"""List active categories (excludes soft-deleted).
Args:
room_id: Room identifier
Returns:
List of active categories
"""
room_data = self._storage.load(room_id)
if room_data is None:
return []
return [c for c in room_data.categories if c.deleted_at is None]
def get_category(self, room_id: int, slug: str) -> Category | None:
"""Get a category by slug (excludes soft-deleted).
Args:
room_id: Room identifier
slug: Category slug
Returns:
Category or None if not found
"""
room_data = self._storage.load(room_id)
if room_data is None:
return None
for cat in room_data.categories:
if cat.id == slug and cat.deleted_at is None:
return cat
return None
def _validate_category_exists(self, room_id: int, slug: str) -> None:
"""Validate that a category exists (and is not deleted). Raises ValueError if not found."""
if not self.get_category(room_id, slug):
raise ValueError(f"Category '{slug}' not found.")
def add_category_to_bounty(
self,
room_id: int,
bounty_id: int,
category_slug: str,
username: str | None,
) -> bool:
"""Add category to a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slug: Category slug to add
username: Requesting admin's username
Returns:
True if newly added, False if already exists
Raises:
PermissionError: If not admin
ValueError: If bounty or category not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can manage bounty categories.")
bounty = self.get_bounty(room_id, bounty_id)
if not bounty:
raise ValueError("Bounty not found.")
self._validate_category_exists(room_id, category_slug)
if category_slug in bounty.category_ids:
return False # Already exists
bounty.category_ids.append(category_slug)
self._storage.update_bounty(room_id, bounty)
return True
def remove_category_from_bounty(
self,
room_id: int,
bounty_id: int,
category_slug: str,
username: str | None,
) -> bool:
"""Remove category from a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slug: Category slug to remove
username: Requesting admin's username
Returns:
True if removed, False if not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can manage bounty categories.")
bounty = self.get_bounty(room_id, bounty_id)
if not bounty:
raise ValueError("Bounty not found.")
if category_slug not in bounty.category_ids:
return False
bounty.category_ids.remove(category_slug)
self._storage.update_bounty(room_id, bounty)
return True
def update_bounty_categories(
self,
room_id: int,
bounty_id: int,
category_slugs: list[str],
username: str | None,
) -> bool:
"""Replace all categories on a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slugs: New list of category slugs
username: Requesting admin's username
Returns:
True if updated
Raises:
PermissionError: If not admin
ValueError: If bounty or any category not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can manage bounty categories.")
bounty = self.get_bounty(room_id, bounty_id)
if not bounty:
raise ValueError("Bounty not found.")
# Validate all categories exist
for slug in category_slugs:
self._validate_category_exists(room_id, slug)
bounty.category_ids = category_slugs
self._storage.update_bounty(room_id, bounty)
return True
class TrackingService: class TrackingService:
"""Service for tracking bounty operations.""" """Service for tracking bounty operations."""

544
docs/AUDIT_AND_SPEC.md Normal file
View File

@@ -0,0 +1,544 @@
# JIGAIDO Audit & Feature Specification
> Document created: 2026-04-09
> Purpose: Repository audit findings and category feature specification
---
# Part I: Repository Audit
## 1.1 Current Architecture
JIGAIDO follows **hexagonal architecture** with clear separation:
```
jigaido/
├── core/ # Domain layer (pure Python, no deps)
│ ├── models.py # Domain dataclasses
│ ├── ports.py # Storage interfaces
│ └── services.py # Business logic
├── adapters/
│ └── storage/
│ └── json_file.py # JSON file persistence
├── apps/
│ └── telegram-bot/
│ ├── bot.py # Bot entrypoint
│ └── commands.py # Command handlers
├── tests/ # Unit tests (98 tests passing)
├── config.py # Configuration
├── SPEC.md # Original design spec
└── README.md # Overview
```
## 1.2 Features Implemented
| Feature | Status | Location |
|---------|--------|----------|
| Group bounty management | ✅ Done | `BountyService` |
| Personal DM bounties | ✅ Done | Same service, different room_id |
| Admin management | ✅ Done | `add_admin`, `remove_admin`, `list_admins` |
| Soft delete (recoverable) | ✅ Done | `delete_bounty`, `recover_bounty` |
| Due date with timezone | ✅ Done | `dateparser` + `ZoneInfo` |
| Link deduplication | ✅ Done | `check_link_unique` |
| Tracking/untracking | ✅ Done | `TrackingService` |
| `/track` in groups only | ✅ Done | Command handler |
| Expired bounty filtering | ✅ Done | 24h cutoff logic |
| Timezone per room | ✅ Done | `set_timezone`, `get_timezone` |
## 1.3 Bugs & Issues Found
### Bug 1: Admin Promotion Logic Edge Case
**Location**: `core/services.py` - `add_admin()`
**Issue**: The first admin can self-promote, but if the Telegram group creator joins later, they won't be recognized as admin since they're not in `admin_usernames`.
**Code**:
```python
# core/services.py:44-49
has_no_admins = room_data is None or not room_data.admin_usernames
is_self_promotion = requesting_username == username
if not self.is_admin(room_id, requesting_username):
if not (has_no_admins and is_self_promotion):
raise PermissionError("Only admins can add admins.")
```
**Recommendation**: Document this behavior or enhance to auto-detect Telegram group creator.
---
### Bug 2: Hard Delete Method in Storage
**Location**: `adapters/storage/json_file.py:113-119`
**Issue**: `delete_bounty()` in storage does hard delete, while `BountyService.delete_bounty()` does soft delete. The storage method is unused but confusing.
**Code**:
```python
def delete_bounty(self, room_id: int, bounty_id: int) -> None:
"""Delete a bounty from a room."""
room_data = self.load(room_id)
if room_data is None:
return
room_data.bounties = [b for b in room_data.bounties if b.id != bounty_id]
self.save(room_data)
```
**Recommendation**: Remove or mark as deprecated.
---
### Issue 3: Spec vs Code Inconsistency
**Location**: `SPEC.md` vs `commands.py`
**Issue**: SPEC.md says anyone can `/add` in groups, but code requires admin.
| Command | SPEC.md | Code |
|---------|---------|------|
| `/add` | anyone | admin only |
| `/edit` | creator only | admin only |
| `/delete` | creator only | admin only |
**Recommendation**: Update SPEC.md to reflect actual implementation.
---
### Issue 4: No Input Sanitization
**Location**: `commands.py` - `parse_args()`
**Issue**: Links without proper scheme (e.g., `github.com/user/repo`) are accepted as-is.
**Recommendation**: Consider normalizing URL scheme or validating format.
---
### Issue 5: No Rate Limiting
**Location**: `/add` command
**Issue**: No limit on:
- Number of bounties per room
- Text length
- Request rate
**Recommendation**: Add rate limiting for production use.
---
## 1.4 Test Status
```bash
$ PYTHONPATH=. python -m pytest tests/
======================== 98 passed, 1 warning ========================
```
**Note**: Tests require `PYTHONPATH=.` to run. Consider adding `pytest.ini` or `pyproject.toml`.
---
# Part II: Category Feature Specification
## 2.1 Overview
Add category support to JIGAIDO to allow filtering and organizing bounties.
**Goals**:
- Admin-only category management (create, delete)
- Multiple categories per bounty (no duplicates)
- Filter bounties by category
- Show categories on `/show` command
- Backward compatibility (existing bounties work without categories)
## 2.2 Data Model
### Category
```python
@dataclass
class Category:
"""A category for organizing bounties in a room."""
id: str # slug: lowercase alphabetic only, e.g., "bug", "feature"
name: str # display name: e.g., "Bug", "Feature"
created_at: int
deleted_at: int | None = None # soft delete
```
**Constraints**:
- `id` (slug): lowercase alphabetic only, no symbols, e.g., `^[a-z]+$`
- `name`: human-readable display
- Unique within room (slug must be unique)
- Soft delete preserves data
### Bounty (Modified)
```python
@dataclass
class Bounty:
# ... existing fields ...
id: int
text: str | None
link: str | None
due_date_ts: int | None
created_at: int
created_by_user_id: int
deleted_at: int | None = None
created_by_username: str | None = None
category_ids: list[str] = field(default_factory=list) # NEW
```
### RoomData (Modified)
```python
@dataclass
class RoomData:
room_id: int
bounties: list[Bounty]
next_id: int
timezone: str | None = None
admin_usernames: list[str] | None = None
categories: list[Category] = field(default_factory=list) # NEW
```
## 2.3 Category Scope
- **Per room**: Same as bounties, each room (group/DM) has independent categories
- **Admin only**: Only admins can create/delete categories
- **User access**: Regular users can only filter by category
## 2.4 Service Layer API
All methods require admin permission unless specified otherwise.
### Category Management
```python
class BountyService:
# ... existing methods ...
# --- Category Management ---
def add_category(
self,
room_id: int,
slug: str,
name: str,
username: str | None
) -> Category:
"""Create a new category. Admin only.
Args:
room_id: Room identifier
slug: Category ID (lowercase alphabetic, e.g., "bug")
name: Display name (e.g., "Bug Report")
username: Requesting admin's username
Returns:
Created Category
Raises:
PermissionError: If not admin
ValueError: If slug already exists or invalid
"""
...
def delete_category(
self,
room_id: int,
slug: str,
username: str | None
) -> bool:
"""Soft delete a category. Admin only.
Args:
room_id: Room identifier
slug: Category slug to delete
username: Requesting admin's username
Returns:
True if deleted, False if not found
"""
...
def list_categories(self, room_id: int) -> list[Category]:
"""List active categories (excludes soft-deleted).
Args:
room_id: Room identifier
Returns:
List of active categories
"""
...
def get_category(self, room_id: int, slug: str) -> Category | None:
"""Get a category by slug (excludes soft-deleted).
Args:
room_id: Room identifier
slug: Category slug
Returns:
Category or None if not found
"""
...
```
### Category-to-Bounty Association
```python
def add_category_to_bounty(
self,
room_id: int,
bounty_id: int,
category_slug: str,
username: str | None
) -> bool:
"""Add category to a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slug: Category slug to add
username: Requesting admin's username
Returns:
True if newly added, False if already exists
Raises:
PermissionError: If not admin
ValueError: If bounty or category not found
"""
...
def remove_category_from_bounty(
self,
room_id: int,
bounty_id: int,
category_slug: str,
username: str | None
) -> bool:
"""Remove category from a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slug: Category slug to remove
username: Requesting admin's username
Returns:
True if removed, False if not found
"""
...
def update_bounty_categories(
self,
room_id: int,
bounty_id: int,
category_slugs: list[str],
username: str | None
) -> bool:
"""Replace all categories on a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slugs: New list of category slugs
username: Requesting admin's username
Returns:
True if updated
Raises:
PermissionError: If not admin
ValueError: If bounty or any category not found
"""
...
```
### Bounty Listing with Category Filter
```python
def list_bounties(
self,
room_id: int,
category_slugs: list[str] | None = None,
include_expired: bool = False
) -> list[Bounty]:
"""List bounties with optional category filtering.
Args:
room_id: Room identifier
category_slugs: If provided, filter by ANY of these categories (OR)
include_expired: If True, include bounties past due date
Returns:
List of non-deleted bounties, sorted by due date
"""
...
```
## 2.5 Filtering Logic
- **Single category**: `/bounty -c bug` → bounties with "bug"
- **Multiple categories (OR)**: `/bounty -c bug,feature` → bounties with "bug" OR "feature"
- **No filter**: `/bounty` → all bounties (current behavior)
## 2.6 Command Syntax
### Category Management
```
/category - list categories
/category add <slug> <name> - create category (admin)
/category delete <slug> - soft delete category (admin)
```
### Bounty with Category
```
/add <text> [link] [date] -cat <slug> - add with category
/add <text> [link] [date] -cat <slug1>,<slug2> - add with multiple categories
/update <id> -cat <slug> - add category to bounty
/update <id> -cat <slug1>,<slug2> - set categories (replace all)
/update <id> -cat - - clear all categories
/update <id> -remove-cat <slug> - remove specific category
```
### Bounty Listing with Filter
```
/bounty - all bounties (current)
/bounty -c <slug> - filter by category
/bounty -c <slug1>,<slug2> - filter by multiple categories (OR)
/bounty all - show expired (current)
/bounty all -c <slug> - show expired + filter by category
```
### Show Bounty
```
/show <id> - show bounty details with categories
```
## 2.7 Display Format
### `/category` output
```
Categories:
- bug → Bug Report
- feature → Feature Request
- docs → Documentation
```
### `/show <id>` output (with categories)
```
[#1] Fix login bug
🔗 https://github.com/...
📅 15 April 2026 14:30 (Asia/Jakarta)
📂 Categories: bug | feature
👤 @username
📌 Created: 2026-04-01 10:00
```
### `/bounty -c bug` output
```
Filtering with 🐛 bug category:
Showing 3 of 10 bounties:
[#5] ...
[#1] ...
[#3] ...
```
### `/bounty -c bug,feature` output
```
Filtering with 🐛 bug, ✨ feature categories:
Showing 5 of 10 bounties:
[#5] ...
[#1] ...
```
## 2.8 Edge Cases
| Scenario | Behavior |
|----------|----------|
| Delete category | Soft delete - existing bounties keep category in data, but filter won't find it |
| Filter by deleted category | Show "No bounties with this category" or error |
| Add duplicate category to bounty | No-op, return False |
| Add invalid slug (uppercase/symbols) | Reject with validation error |
| Category slug conflict | Reject with "Category already exists" |
| Bounty without categories | `category_ids = []` (backward compatible) |
## 2.9 Test Cases to Add
```python
# Category Management
def test_add_category_requires_admin():
def test_add_category_duplicate_slug_fails():
def test_add_category_invalid_slug_fails():
def test_add_category_valid():
def test_delete_category_soft_deletes():
def test_deleted_category_not_listed():
def test_deleted_category_still_in_bounty_data():
def test_list_categories_empty():
def test_list_categories_returns_active():
def test_get_category_not_found():
def test_get_category_deleted_returns_none():
# Category-to-Bounty
def test_add_category_to_bounty():
def test_add_duplicate_category_to_bounty_noop():
def test_add_category_to_bounty_invalid_category():
def test_remove_category_from_bounty():
def test_remove_category_not_on_bounty_returns_false():
def test_update_bounty_categories_replace_all():
def test_update_bounty_categories_validates():
# Bounty Listing with Filter
def test_list_bounties_filter_by_single_category():
def test_list_bounties_filter_by_multiple_categories_or():
def test_list_bounties_no_category_returns_all():
def test_list_bounties_category_excludes_deleted_bounties():
```
---
# Part III: Implementation Checklist
## Models Layer
- [ ] Add `Category` dataclass to `core/models.py`
- [ ] Add `category_ids` field to `Bounty` dataclass
- [ ] Add `categories` field to `RoomData` dataclass
## Storage Layer
- [ ] Update `JsonFileRoomStorage.load()` to deserialize categories
- [ ] Update `JsonFileRoomStorage.save()` to serialize categories
## Service Layer
- [ ] Implement `add_category()`
- [ ] Implement `delete_category()`
- [ ] Implement `list_categories()`
- [ ] Implement `get_category()`
- [ ] Implement `add_category_to_bounty()`
- [ ] Implement `remove_category_from_bounty()`
- [ ] Implement `update_bounty_categories()`
- [ ] Update `list_bounties()` to support category filter
## Command Layer
- [ ] Add `/category` command handler
- [ ] Add `-cat` flag parsing to `/add`
- [ ] Add `-cat` and `-remove-cat` flags to `/update`
- [ ] Add `-c` flag to `/bounty` for category filter
- [ ] Update `/show` to display categories
## Tests
- [ ] Add category management tests
- [ ] Add category-to-bounty tests
- [ ] Add category filter tests
## Documentation
- [ ] Update README.md with category feature
- [ ] Update command help text
---
# Part IV: Open Questions
1. **Category icon/emoji**: Should categories have optional emoji? (Not in initial spec, can add later)
2. **Category reactivation**: Should soft-deleted categories be reactable? (Not in initial spec)
3. **Bulk category operations**: Should we support `/category add bulk`? (Not in initial spec)
---
*End of Audit & Specification Document*

4
pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
pythonpath = .
asyncio_default_fixture_loop_scope = function

View File

@@ -3,7 +3,6 @@
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from io import StringIO from io import StringIO
import sys
from core.models import Bounty from core.models import Bounty
from core.ports import RoomStorage, TrackingStorage from core.ports import RoomStorage, TrackingStorage
@@ -237,7 +236,7 @@ class TestCLIValidation:
main() main()
mock_bounty_service.update_bounty.assert_called_once() mock_bounty_service.update_bounty.assert_called_once()
call_kwargs = mock_bounty_service.update_bounty.call_args call_kwargs = mock_bounty_service.update_bounty.call_args
assert call_kwargs.kwargs.get("clear_link") == True assert call_kwargs.kwargs.get("clear_link") is True
def test_update_clear_due_flag(self): def test_update_clear_due_flag(self):
"""Test update with --clear-due flag.""" """Test update with --clear-due flag."""
@@ -255,7 +254,7 @@ class TestCLIValidation:
main() main()
mock_bounty_service.update_bounty.assert_called_once() mock_bounty_service.update_bounty.assert_called_once()
call_kwargs = mock_bounty_service.update_bounty.call_args call_kwargs = mock_bounty_service.update_bounty.call_args
assert call_kwargs.kwargs.get("clear_due") == True assert call_kwargs.kwargs.get("clear_due") is True
class TestCLIOutput: class TestCLIOutput:

View File

@@ -2,11 +2,9 @@
import json import json
import os import os
import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest
from config import Config, DEFAULT_DATA_DIR from config import Config, DEFAULT_DATA_DIR
@@ -47,10 +45,27 @@ class TestConfigDataDir:
assert cfg.bot_token == "test_token_123" assert cfg.bot_token == "test_token_123"
def test_bot_token_none_when_not_set(self): def test_bot_token_none_when_not_set(self):
"""Test that bot_token is None when JIGAIDO_BOT_TOKEN not set.""" """Test that bot_token is None when JIGAIDO_BOT_TOKEN not set and no config file."""
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
cfg = Config() with patch("pathlib.Path.exists", return_value=False):
assert cfg.bot_token is None cfg = Config()
assert cfg.bot_token is None
def test_bot_token_from_config_file(self):
"""Test that bot_token is read from config file when env var not set."""
config_dir = Path.home() / ".jigaido"
config_file = config_dir / "config.json"
with patch.dict(os.environ, {}, clear=True):
with patch("pathlib.Path.expanduser", return_value=config_file):
with patch("pathlib.Path.exists", return_value=True):
with patch("builtins.open", create=True) as mock_open:
mock_open.return_value.__enter__ = lambda s: s
mock_open.return_value.__exit__ = lambda *a: None
mock_open.return_value.read = lambda: (
'{"JIGAIDO_BOT_TOKEN": "config_token"}'
)
cfg = Config()
assert cfg.bot_token == "config_token"
class TestConfigEnsureDataDir: class TestConfigEnsureDataDir:

View File

@@ -1,11 +1,9 @@
"""Tests for adapters/storage/json_file.py — JSON file storage adapter.""" """Tests for adapters/storage/json_file.py — JSON file storage adapter."""
import json import json
import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
import pytest
from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
from core.models import Bounty, RoomData, TrackingData, TrackedBounty from core.models import Bounty, RoomData, TrackingData, TrackedBounty
@@ -99,15 +97,6 @@ class TestJsonFileRoomStorage:
assert self.storage.load(-1001) is None assert self.storage.load(-1001) is None
def test_delete_bounty(self):
"""Test that delete_bounty removes a bounty."""
bounty = self._create_bounty(id=1)
self.storage.add_bounty(-1001, bounty)
self.storage.delete_bounty(-1001, 1)
loaded = self.storage.load(-1001)
assert len(loaded.bounties) == 0
def test_get_bounty_found(self): def test_get_bounty_found(self):
"""Test that get_bounty returns the bounty when found.""" """Test that get_bounty returns the bounty when found."""
bounty = self._create_bounty(id=1) bounty = self._create_bounty(id=1)

View File

@@ -1,8 +1,6 @@
"""Tests for core/models.py — domain dataclasses.""" """Tests for core/models.py — domain dataclasses."""
import time
import pytest
from core.models import ( from core.models import (
Bounty, Bounty,
@@ -122,7 +120,7 @@ class TestRoomData:
assert rd.bounties == [] assert rd.bounties == []
assert rd.next_id == 1 assert rd.next_id == 1
assert rd.timezone is None assert rd.timezone is None
assert rd.admin_user_ids == [] assert rd.admin_usernames == []
def test_create_dm_room_data(self): def test_create_dm_room_data(self):
rd = RoomData( rd = RoomData(
@@ -134,7 +132,7 @@ class TestRoomData:
assert rd.bounties == [] assert rd.bounties == []
assert rd.next_id == 1 assert rd.next_id == 1
assert rd.timezone is None assert rd.timezone is None
assert rd.admin_user_ids == [] assert rd.admin_usernames == []
def test_room_data_with_bounties(self): def test_room_data_with_bounties(self):
b = Bounty( b = Bounty(
@@ -156,18 +154,18 @@ class TestRoomData:
bounties=[], bounties=[],
next_id=1, next_id=1,
timezone="Asia/Jakarta", timezone="Asia/Jakarta",
admin_user_ids=[123, 456], admin_usernames=["alice", "bob"],
) )
assert rd.timezone == "Asia/Jakarta" assert rd.timezone == "Asia/Jakarta"
assert rd.admin_user_ids == [123, 456] assert rd.admin_usernames == ["alice", "bob"]
def test_room_data_admin_user_ids_defaults_to_empty_list(self): def test_room_data_admin_usernames_defaults_to_empty_list(self):
rd = RoomData( rd = RoomData(
room_id=-1001, room_id=-1001,
bounties=[], bounties=[],
next_id=1, next_id=1,
) )
assert rd.admin_user_ids == [] assert rd.admin_usernames == []
class TestTrackingData: class TestTrackingData:

View File

@@ -1,6 +1,5 @@
"""Tests for core/ports.py — storage interfaces.""" """Tests for core/ports.py — storage interfaces."""
import pytest
from core.models import Bounty, RoomData, TrackingData, TrackedBounty from core.models import Bounty, RoomData, TrackingData, TrackedBounty
from core.ports import RoomStorage, TrackingStorage from core.ports import RoomStorage, TrackingStorage
@@ -35,12 +34,6 @@ class SimpleRoomStorage:
self._rooms[room_id].bounties[i] = bounty self._rooms[room_id].bounties[i] = bounty
break 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: def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
if room_id in self._rooms: if room_id in self._rooms:
for b in self._rooms[room_id].bounties: for b in self._rooms[room_id].bounties:
@@ -120,12 +113,6 @@ class MockRoomStorage:
self._rooms[room_id].bounties[i] = bounty self._rooms[room_id].bounties[i] = bounty
break 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: def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
if room_id in self._rooms: if room_id in self._rooms:
for b in self._rooms[room_id].bounties: for b in self._rooms[room_id].bounties:
@@ -243,19 +230,6 @@ class TestRoomStorage:
assert result is not None assert result is not None
assert result.text == "Updated" assert result.text == "Updated"
def test_delete_bounty(self):
storage = MockRoomStorage()
bounty = Bounty(
id=1,
text="Test",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
)
storage.add_bounty(-1001, bounty)
storage.delete_bounty(-1001, 1)
assert storage.get_bounty(-1001, 1) is None
class TestTrackingStorage: class TestTrackingStorage:

View File

@@ -1,9 +1,8 @@
"""Tests for core/services.py — business logic services.""" """Tests for core/services.py — business logic services."""
import pytest import pytest
from unittest.mock import MagicMock
from core.models import Bounty, RoomData, TrackingData, TrackedBounty from core.models import RoomData
from core.services import BountyService, TrackingService from core.services import BountyService, TrackingService
from tests.test_ports import MockRoomStorage, MockTrackingStorage from tests.test_ports import MockRoomStorage, MockTrackingStorage
@@ -15,32 +14,34 @@ 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.admin_username = "admin"
self._make_admin(-1001, self.admin_user_id) self._make_admin(-1001, self.admin_username)
def _make_admin(self, room_id: int, user_id: int): def _make_admin(self, room_id: int, username: str):
"""Helper to set up a room with an admin user.""" """Helper to set up a room with an admin user."""
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_data = RoomData(
room_id=room_id, bounties=[], next_id=0, admin_user_ids=[] room_id=room_id, bounties=[], next_id=0, admin_usernames=[]
) )
if user_id not in (room_data.admin_user_ids or []): if username not in (room_data.admin_usernames or []):
room_data.admin_user_ids = room_data.admin_user_ids or [] room_data.admin_usernames = room_data.admin_usernames or []
room_data.admin_user_ids.append(user_id) room_data.admin_usernames.append(username)
self.storage.save(room_data) 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=self.admin_user_id, user_id=123,
username=self.admin_username,
text="Fix bug", text="Fix bug",
link="https://github.com/issue/1", link="https://github.com/issue/1",
created_by_username=self.admin_username,
) )
assert bounty.id == 1 assert bounty.id == 1
assert bounty.text == "Fix bug" assert bounty.text == "Fix bug"
assert bounty.created_by_user_id == self.admin_user_id assert bounty.created_by_user_id == 123
room = self.storage.load(-1001) room = self.storage.load(-1001)
assert room is not None assert room is not None
@@ -49,13 +50,13 @@ 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( b1 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="First" room_id=-1001, user_id=123, username=self.admin_username, text="First"
) )
b2 = self.service.add_bounty( b2 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Second" room_id=-1001, user_id=123, username=self.admin_username, text="Second"
) )
b3 = self.service.add_bounty( b3 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Third" room_id=-1001, user_id=123, username=self.admin_username, text="Third"
) )
assert b1.id == 1 assert b1.id == 1
@@ -65,7 +66,9 @@ class TestBountyService:
def test_add_bounty_requires_admin(self): def test_add_bounty_requires_admin(self):
"""Test that add_bounty raises PermissionError when non-admin tries to add.""" """Test that add_bounty raises PermissionError when non-admin tries to add."""
with pytest.raises(PermissionError, match="Only admins can add bounties"): with pytest.raises(PermissionError, match="Only admins can add bounties"):
self.service.add_bounty(room_id=-1001, user_id=999, text="Not admin") self.service.add_bounty(
room_id=-1001, user_id=999, username="nonadmin", 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."""
@@ -74,14 +77,16 @@ 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=self.admin_user_id, text="First")
self.service.add_bounty( self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Second" room_id=-1001, user_id=123, username=self.admin_username, text="First"
)
self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="Second"
) )
# Add bounty to different room to verify isolation # Add bounty to different room to verify isolation
self._make_admin(-999, self.admin_user_id) self._make_admin(-999, "otheradmin")
self.service.add_bounty( self.service.add_bounty(
room_id=-999, user_id=self.admin_user_id, text="Other room" room_id=-999, user_id=456, username="otheradmin", text="Other room"
) )
bounties = self.service.list_bounties(-1001) bounties = self.service.list_bounties(-1001)
@@ -91,7 +96,7 @@ 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( created = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Test" room_id=-1001, user_id=123, username=self.admin_username, 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
@@ -104,19 +109,21 @@ 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=self.admin_user_id, text="Test") self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, 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 admin updates their bounty.""" """Test update_bounty succeeds when admin updates their bounty."""
bounty = self.service.add_bounty( bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Original" room_id=-1001, user_id=123, username=self.admin_username, 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=self.admin_user_id, username=self.admin_username,
text="Updated", text="Updated",
) )
assert result is True assert result is True
@@ -126,13 +133,13 @@ class TestBountyService:
def test_update_bounty_not_admin_raises_permission_error(self): def test_update_bounty_not_admin_raises_permission_error(self):
"""Test update_bounty raises PermissionError when non-admin tries to update.""" """Test update_bounty raises PermissionError when non-admin tries to update."""
bounty = self.service.add_bounty( bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Original" room_id=-1001, user_id=123, username=self.admin_username, text="Original"
) )
with pytest.raises(PermissionError, match="Only admins can edit bounties"): 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, not admin username="nonadmin", # different user, not admin
text="Hacked", text="Hacked",
) )
@@ -141,7 +148,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=self.admin_user_id, username=self.admin_username,
text="Updated", text="Updated",
) )
assert result is False assert result is False
@@ -150,14 +157,15 @@ 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=self.admin_user_id, user_id=123,
username=self.admin_username,
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=self.admin_user_id, username=self.admin_username,
text="Updated only text", text="Updated only text",
) )
updated = self.service.get_bounty(-1001, bounty.id) updated = self.service.get_bounty(-1001, bounty.id)
@@ -168,14 +176,15 @@ 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=self.admin_user_id, user_id=123,
username=self.admin_username,
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=self.admin_user_id, username=self.admin_username,
clear_link=True, clear_link=True,
) )
updated = self.service.get_bounty(-1001, bounty.id) updated = self.service.get_bounty(-1001, bounty.id)
@@ -184,9 +193,9 @@ class TestBountyService:
def test_delete_bounty_success(self): def test_delete_bounty_success(self):
"""Test delete_bounty soft deletes when admin deletes their bounty.""" """Test delete_bounty soft deletes when admin deletes their bounty."""
bounty = self.service.add_bounty( bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="To delete" room_id=-1001, user_id=123, username=self.admin_username, text="To delete"
) )
result = self.service.delete_bounty(-1001, bounty.id, self.admin_user_id) result = self.service.delete_bounty(-1001, bounty.id, self.admin_username)
assert result is True assert result is True
# Soft delete - bounty should not be found via get_bounty # 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
@@ -198,18 +207,56 @@ class TestBountyService:
def test_delete_bounty_not_admin_raises_permission_error(self): def test_delete_bounty_not_admin_raises_permission_error(self):
"""Test delete_bounty raises PermissionError when non-admin tries to delete.""" """Test delete_bounty raises PermissionError when non-admin tries to delete."""
bounty = self.service.add_bounty( bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="To delete" room_id=-1001, user_id=123, username=self.admin_username, text="To delete"
) )
with pytest.raises(PermissionError, match="Only admins can delete bounties"): with pytest.raises(PermissionError, match="Only admins can delete bounties"):
self.service.delete_bounty( self.service.delete_bounty(-1001, bounty.id, "nonadmin")
-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, self.admin_user_id) result = self.service.delete_bounty(-1001, 999, self.admin_username)
assert result is False assert result is False
def test_delete_bounties_multi_id_success(self):
"""Test delete_bounties returns individual results for multiple bounties."""
bounty1 = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="To delete 1"
)
bounty2 = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="To delete 2"
)
results = self.service.delete_bounties(
-1001, [bounty1.id, bounty2.id], self.admin_username
)
assert results == {bounty1.id: "deleted", bounty2.id: "deleted"}
# Verify both are soft deleted
assert self.service.get_bounty(-1001, bounty1.id) is None
assert self.service.get_bounty(-1001, bounty2.id) is None
def test_delete_bounties_mixed_results(self):
"""Test delete_bounties returns not_found for non-existent bounties."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="To delete"
)
results = self.service.delete_bounties(
-1001, [bounty.id, 999, 888], self.admin_username
)
assert results == {bounty.id: "deleted", 999: "not_found", 888: "not_found"}
def test_delete_bounties_permission_denied(self):
"""Test delete_bounties returns permission_denied for non-admin users."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="To delete"
)
results = self.service.delete_bounties(
-1001,
[bounty.id],
"nonadmin", # non-admin user
)
assert results == {bounty.id: "permission_denied"}
# Verify bounty was NOT deleted
assert self.service.get_bounty(-1001, bounty.id) is not None
class TestTrackingService: class TestTrackingService:
"""Unit tests for TrackingService.""" """Unit tests for TrackingService."""
@@ -219,29 +266,31 @@ 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.admin_username = "admin"
self._make_admin(-1001, self.admin_user_id) self._make_admin(-1001, self.admin_username)
def _make_admin(self, room_id: int, user_id: int): def _make_admin(self, room_id: int, username: str):
"""Helper to set up a room with an admin user.""" """Helper to set up a room with an admin user."""
room_data = self.room_storage.load(room_id) room_data = self.room_storage.load(room_id)
if room_data is None: if room_data is None:
room_data = RoomData( room_data = RoomData(
room_id=room_id, bounties=[], next_id=0, admin_user_ids=[] room_id=room_id, bounties=[], next_id=0, admin_usernames=[]
) )
if user_id not in (room_data.admin_user_ids or []): if username not in (room_data.admin_usernames or []):
room_data.admin_user_ids = room_data.admin_user_ids or [] room_data.admin_usernames = room_data.admin_usernames or []
room_data.admin_user_ids.append(user_id) room_data.admin_usernames.append(username)
self.room_storage.save(room_data) 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, username="admin", 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 ( if self.room_storage.load(room_id) is None or username not in (
self.room_storage.load(room_id).admin_user_ids or [] self.room_storage.load(room_id).admin_usernames or []
): ):
self._make_admin(room_id, user_id) self._make_admin(room_id, username)
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=123, username=username, text=text
)
def test_track_bounty_success(self): def test_track_bounty_success(self):
"""Test track_bounty successfully tracks a bounty.""" """Test track_bounty successfully tracks a bounty."""
@@ -329,11 +378,13 @@ class TestTrackingService:
def test_get_tracked_bounties_ignores_deleted_bounties(self): def test_get_tracked_bounties_ignores_deleted_bounties(self):
"""Test get_tracked_bounties ignores bounties that were deleted.""" """Test get_tracked_bounties ignores bounties that were deleted."""
bounty_service = BountyService(self.room_storage) bounty_service = BountyService(self.room_storage)
bounty = bounty_service.add_bounty(room_id=-1001, user_id=123, text="To delete") bounty = bounty_service.add_bounty(
room_id=-1001, user_id=123, username="admin", text="To delete"
)
self.service.track_bounty(-1001, 123456, bounty.id) self.service.track_bounty(-1001, 123456, bounty.id)
# Delete the bounty # Delete the bounty
bounty_service.delete_bounty(-1001, bounty.id, 123) bounty_service.delete_bounty(-1001, bounty.id, "admin")
tracked = self.service.get_tracked_bounties(-1001, 123456) tracked = self.service.get_tracked_bounties(-1001, 123456)
assert len(tracked) == 0 # deleted bounty not returned assert len(tracked) == 0 # deleted bounty not returned
@@ -353,3 +404,283 @@ class TestTrackingService:
assert len(tracked_room2) == 1 assert len(tracked_room2) == 1
assert tracked_room1[0].text == "Room 1" assert tracked_room1[0].text == "Room 1"
assert tracked_room2[0].text == "Room 2" assert tracked_room2[0].text == "Room 2"
class TestCategoryService:
"""Unit tests for category management."""
def setup_method(self):
"""Set up fresh storage and service for each test."""
self.storage = MockRoomStorage()
self.service = BountyService(self.storage)
self.admin_username = "admin"
self._make_admin(-1001, self.admin_username)
def _make_admin(self, room_id: int, username: str):
"""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_usernames=[]
)
if username not in (room_data.admin_usernames or []):
room_data.admin_usernames = room_data.admin_usernames or []
room_data.admin_usernames.append(username)
self.storage.save(room_data)
def _add_bounty(self, text="Test bounty"):
"""Helper to add a bounty for category tests."""
return self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text=text
)
# Category Management Tests
def test_add_category_requires_admin(self):
"""Test that add_category raises PermissionError for non-admin."""
with pytest.raises(PermissionError, match="Only admins can add categories"):
self.service.add_category(-1001, "bug", "Bug Report", "nonadmin")
def test_add_category_duplicate_slug_fails(self):
"""Test that adding a duplicate category slug raises ValueError."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
with pytest.raises(ValueError, match="already exists"):
self.service.add_category(-1001, "bug", "Bug Report 2", self.admin_username)
def test_add_category_invalid_slug_fails_uppercase(self):
"""Test that uppercase slug raises ValueError."""
with pytest.raises(ValueError, match="lowercase alphabetic only"):
self.service.add_category(-1001, "Bug", "Bug Report", self.admin_username)
def test_add_category_invalid_slug_fails_with_numbers(self):
"""Test that slug with numbers raises ValueError."""
with pytest.raises(ValueError, match="lowercase alphabetic only"):
self.service.add_category(-1001, "bug1", "Bug 1", self.admin_username)
def test_add_category_invalid_slug_fails_with_symbols(self):
"""Test that slug with symbols raises ValueError."""
with pytest.raises(ValueError, match="lowercase alphabetic only"):
self.service.add_category(-1001, "bug-fix", "Bug Fix", self.admin_username)
def test_add_category_invalid_slug_fails_empty(self):
"""Test that empty slug raises ValueError."""
with pytest.raises(ValueError, match="lowercase alphabetic only"):
self.service.add_category(-1001, "", "Empty", self.admin_username)
def test_add_category_valid(self):
"""Test that valid category can be created."""
category = self.service.add_category(
-1001, "bug", "Bug Report", self.admin_username
)
assert category.id == "bug"
assert category.name == "Bug Report"
assert category.created_at > 0
assert category.deleted_at is None
def test_delete_category_soft_deletes(self):
"""Test that delete_category performs soft delete."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
result = self.service.delete_category(-1001, "bug", self.admin_username)
assert result is True
# Category should not be found via get_category
assert self.service.get_category(-1001, "bug") is None
# But should still be in raw room data (soft delete)
room_data = self.storage.load(-1001)
for cat in room_data.categories:
if cat.id == "bug":
assert cat.deleted_at is not None
return
assert False, "Category should still exist in storage"
def test_deleted_category_not_listed(self):
"""Test that list_categories excludes soft-deleted categories."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
self.service.delete_category(-1001, "bug", self.admin_username)
categories = self.service.list_categories(-1001)
assert len(categories) == 0
assert not any(cat.id == "bug" for cat in categories)
def test_list_categories_empty(self):
"""Test that list_categories returns empty list for room with no categories."""
categories = self.service.list_categories(-1001)
assert categories == []
def test_list_categories_returns_active(self):
"""Test that list_categories returns only active categories."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
self.service.add_category(-1001, "feature", "Feature Request", self.admin_username)
self.service.add_category(-1001, "docs", "Documentation", self.admin_username)
self.service.delete_category(-1001, "bug", self.admin_username)
categories = self.service.list_categories(-1001)
assert len(categories) == 2
category_ids = [c.id for c in categories]
assert "feature" in category_ids
assert "docs" in category_ids
assert "bug" not in category_ids
def test_get_category_not_found(self):
"""Test that get_category returns None for non-existent category."""
category = self.service.get_category(-1001, "nonexistent")
assert category is None
def test_get_category_deleted_returns_none(self):
"""Test that get_category returns None for soft-deleted category."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
self.service.delete_category(-1001, "bug", self.admin_username)
category = self.service.get_category(-1001, "bug")
assert category is None
def test_add_category_requires_admin_non_existent_room(self):
"""Test that add_category works for non-existent room (creates it)."""
self._make_admin(-9999, self.admin_username)
category = self.service.add_category(
-9999, "bug", "Bug Report", self.admin_username
)
assert category.id == "bug"
# Category-to-Bounty Association Tests
def test_add_category_to_bounty(self):
"""Test adding category to a bounty."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
result = self.service.add_category_to_bounty(
-1001, bounty.id, "bug", self.admin_username
)
assert result is True
updated_bounty = self.service.get_bounty(-1001, bounty.id)
assert "bug" in updated_bounty.category_ids
def test_add_duplicate_category_to_bounty_noop(self):
"""Test that adding duplicate category returns False."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username)
result = self.service.add_category_to_bounty(
-1001, bounty.id, "bug", self.admin_username
)
assert result is False
def test_add_category_to_bounty_invalid_bounty(self):
"""Test that adding category to non-existent bounty raises ValueError."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
with pytest.raises(ValueError, match="Bounty not found"):
self.service.add_category_to_bounty(-1001, 999, "bug", self.admin_username)
def test_add_category_to_bounty_invalid_category(self):
"""Test that adding non-existent category raises ValueError."""
bounty = self._add_bounty()
with pytest.raises(ValueError, match="not found"):
self.service.add_category_to_bounty(
-1001, bounty.id, "nonexistent", self.admin_username
)
def test_remove_category_from_bounty(self):
"""Test removing category from a bounty."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username)
result = self.service.remove_category_from_bounty(
-1001, bounty.id, "bug", self.admin_username
)
assert result is True
updated_bounty = self.service.get_bounty(-1001, bounty.id)
assert "bug" not in updated_bounty.category_ids
def test_remove_category_not_on_bounty_returns_false(self):
"""Test that removing category not on bounty returns False."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
result = self.service.remove_category_from_bounty(
-1001, bounty.id, "bug", self.admin_username
)
assert result is False
def test_update_bounty_categories_replace_all(self):
"""Test that update_bounty_categories replaces all categories."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
self.service.add_category(-1001, "feature", "Feature Request", self.admin_username)
self.service.add_category(-1001, "docs", "Documentation", self.admin_username)
bounty = self._add_bounty()
# Add initial categories
self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username)
# Replace with different categories
self.service.update_bounty_categories(
-1001, bounty.id, ["feature", "docs"], self.admin_username
)
updated_bounty = self.service.get_bounty(-1001, bounty.id)
assert updated_bounty.category_ids == ["feature", "docs"]
def test_update_bounty_categories_clear_all(self):
"""Test that update_bounty_categories can clear all categories."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username)
self.service.update_bounty_categories(-1001, bounty.id, [], self.admin_username)
updated_bounty = self.service.get_bounty(-1001, bounty.id)
assert updated_bounty.category_ids == []
def test_update_bounty_categories_validates(self):
"""Test that update_bounty_categories validates all slugs."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
with pytest.raises(ValueError, match="not found"):
self.service.update_bounty_categories(
-1001, bounty.id, ["bug", "nonexistent"], self.admin_username
)
def test_add_category_to_bounty_requires_admin(self):
"""Test that adding category to bounty requires admin."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
with pytest.raises(PermissionError, match="Only admins"):
self.service.add_category_to_bounty(
-1001, bounty.id, "bug", "nonadmin"
)
def test_remove_category_from_bounty_requires_admin(self):
"""Test that removing category from bounty requires admin."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username)
with pytest.raises(PermissionError, match="Only admins"):
self.service.remove_category_from_bounty(
-1001, bounty.id, "bug", "nonadmin"
)
def test_update_bounty_categories_requires_admin(self):
"""Test that updating bounty categories requires admin."""
bounty = self._add_bounty()
with pytest.raises(PermissionError, match="Only admins"):
self.service.update_bounty_categories(
-1001, bounty.id, ["bug"], "nonadmin"
)
def test_delete_category_requires_admin(self):
"""Test that deleting category requires admin."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
with pytest.raises(PermissionError, match="Only admins"):
self.service.delete_category(-1001, "bug", "nonadmin")