From e6c52c31dff305cf7290b544aae3b6dd3b5cac5b Mon Sep 17 00:00:00 2001 From: The Chronicler Date: Tue, 24 Feb 2026 10:36:49 +0000 Subject: [PATCH] feat: Add complete modpack-version-checker production code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete Python package from Claude Code session: - src/modpack_checker/: 1,154 lines (cli, config, curseforge, database, notifier) - tests/: 913 lines (comprehensive test suite) - docs/: README, API, INSTALLATION guides - setup.py, requirements.txt, LICENSE (MIT) Total: 2,121+ lines of production-ready code Ready for BuiltByBit marketplace deployment Transferred via tar.gz from Claude Code → Chronicler #26 --- .../code/modpack-version-checker/.gitignore | 41 +- .../code/modpack-version-checker/LICENSE | 22 +- .../code/modpack-version-checker/docs/API.md | 228 +++++++ .../docs/INSTALLATION.md | 166 +++++ .../modpack-version-checker/docs/README.md | 91 +++ .../modpack-version-checker/requirements.txt | 17 +- .../code/modpack-version-checker/setup.cfg | 16 +- .../code/modpack-version-checker/setup.py | 62 +- .../src/modpack_checker/cli.py | 566 +++++++++++++++++- .../src/modpack_checker/config.py | 1 + .../src/modpack_checker/curseforge.py | 193 +++++- .../src/modpack_checker/database.py | 226 ++++++- .../src/modpack_checker/notifier.py | 123 +++- .../modpack-version-checker/tests/__init__.py | 0 .../modpack-version-checker/tests/conftest.py | 12 +- .../modpack-version-checker/tests/test_cli.py | 339 +++++++++++ .../tests/test_config.py | 73 ++- .../tests/test_curseforge.py | 228 ++++++- .../tests/test_database.py | 175 +++++- .../tests/test_notifier.py | 84 ++- 20 files changed, 2649 insertions(+), 14 deletions(-) create mode 100644 docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/API.md create mode 100644 docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/INSTALLATION.md create mode 100644 docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/README.md create mode 100644 docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/__init__.py create mode 100644 docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_cli.py diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/.gitignore b/docs/tasks/modpack-version-checker/code/modpack-version-checker/.gitignore index ac01d92..0686131 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/.gitignore +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/.gitignore @@ -1 +1,40 @@ -# Placeholder - content in chat history +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.egg +*.egg-info/ +dist/ +build/ +.eggs/ +*.whl + +# Virtual environments +.env +.venv/ +venv/ +env/ + +# Database files (user data — never commit) +*.db +*.sqlite +*.sqlite3 + +# Test / coverage artifacts +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Editor +.vscode/ +.idea/ +*.swp +*~ + +# macOS +.DS_Store + +# Config (may contain secrets) +~/.config/modpack-checker/ diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/LICENSE b/docs/tasks/modpack-version-checker/code/modpack-version-checker/LICENSE index ac01d92..039ce79 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/LICENSE +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/LICENSE @@ -1 +1,21 @@ -# Placeholder - content in chat history +MIT License + +Copyright (c) 2026 Firefrost Gaming + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/API.md b/docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/API.md new file mode 100644 index 0000000..7e9abd7 --- /dev/null +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/API.md @@ -0,0 +1,228 @@ +# CLI Reference + +Full reference for all `modpack-checker` commands and flags. + +--- + +## Global Flags + +| Flag | Description | +|---|---| +| `--version` | Show version and exit | +| `-h`, `--help` | Show help for any command | + +--- + +## config + +Manage configuration settings stored in `~/.config/modpack-checker/config.json`. + +### config set-key + +``` +modpack-checker config set-key API_KEY +``` + +Save and validate a CurseForge API key. The key is tested against the API immediately; a warning is shown if validation fails but the key is still saved. + +**Get a free key at:** https://console.curseforge.com + +--- + +### config set-webhook + +``` +modpack-checker config set-webhook WEBHOOK_URL +``` + +Save a Discord webhook URL. A test embed is sent immediately to confirm the webhook works. + +--- + +### config set-interval + +``` +modpack-checker config set-interval HOURS +``` + +Set how many hours the scheduler waits between checks. Accepts values 1–168. + +**Default:** 6 + +--- + +### config show + +``` +modpack-checker config show +``` + +Display the current configuration. The API key is masked (first 4 / last 4 characters shown). + +--- + +## add + +``` +modpack-checker add MODPACK_ID +``` + +Add a CurseForge modpack to the watch list. The modpack name is fetched from the API and stored locally. Run `check` afterward to record the initial version. + +**Arguments:** + +| Argument | Type | Description | +|---|---|---| +| `MODPACK_ID` | int | CurseForge project ID (from the modpack's URL) | + +**Example:** +```bash +modpack-checker add 238222 # All The Mods 9 +``` + +--- + +## remove + +``` +modpack-checker remove MODPACK_ID +``` + +Remove a modpack and all its check history. Prompts for confirmation. + +**Arguments:** + +| Argument | Type | Description | +|---|---|---| +| `MODPACK_ID` | int | CurseForge project ID | + +--- + +## list + +``` +modpack-checker list +``` + +Display all watched modpacks in a table showing: +- CurseForge ID +- Name +- Last known version +- Last check timestamp +- Whether Discord alerts are enabled + +--- + +## check + +``` +modpack-checker check [OPTIONS] +``` + +Check watched modpacks against the CurseForge API and report on their status. + +**Options:** + +| Flag | Description | +|---|---| +| `--id INTEGER`, `-m INTEGER` | Check only this modpack ID | +| `--notify` / `--no-notify` | Send Discord notifications (default: on) | + +**Output:** +- `✓` — up to date +- `↑` — update available (version shown) +- `→` — initial version recorded (first check) +- `✗` — API error + +--- + +## status + +``` +modpack-checker status MODPACK_ID [OPTIONS] +``` + +Show a detailed panel for one modpack plus its check history. + +**Arguments:** + +| Argument | Type | Description | +|---|---|---| +| `MODPACK_ID` | int | CurseForge project ID | + +**Options:** + +| Flag | Default | Description | +|---|---|---| +| `--limit INTEGER`, `-n INTEGER` | 10 | Number of history entries to display | + +--- + +## notifications + +``` +modpack-checker notifications MODPACK_ID [--enable | --disable] +``` + +Toggle Discord alerts for a specific modpack without removing it from the watch list. + +**Options:** + +| Flag | Description | +|---|---| +| `--enable` | Enable notifications (default) | +| `--disable` | Suppress notifications for this modpack | + +--- + +## schedule + +``` +modpack-checker schedule [OPTIONS] +``` + +Start a blocking background process that checks for updates on a repeating interval. Requires the `[scheduler]` extra (`pip install "modpack-version-checker[scheduler]"`). + +**Options:** + +| Flag | Description | +|---|---| +| `--hours INTEGER`, `-h INTEGER` | Override the configured interval | + +Runs a check immediately on startup, then repeats at the configured interval. Press `Ctrl-C` to stop. + +--- + +## Configuration File + +Location: `~/.config/modpack-checker/config.json` + +```json +{ + "curseforge_api_key": "your-key-here", + "discord_webhook_url": "https://discord.com/api/webhooks/...", + "database_path": "/home/user/.config/modpack-checker/modpacks.db", + "check_interval_hours": 6, + "notification_on_update": true +} +``` + +--- + +## Database + +Location: `~/.config/modpack-checker/modpacks.db` (SQLite) + +The database is managed automatically. To reset completely: +```bash +rm ~/.config/modpack-checker/modpacks.db +``` + +--- + +## Exit Codes + +| Code | Meaning | +|---|---| +| `0` | Success | +| `1` | Error (API failure, modpack not found, missing config, etc.) | diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/INSTALLATION.md b/docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/INSTALLATION.md new file mode 100644 index 0000000..eb59a92 --- /dev/null +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/INSTALLATION.md @@ -0,0 +1,166 @@ +# Installation Guide + +## Requirements + +- Python 3.9 or newer +- pip (comes with Python) +- A free CurseForge API key + +--- + +## Step 1 — Install Python + +Verify Python is installed: + +```bash +python3 --version +# Should show: Python 3.9.x or newer +``` + +If not installed, download from [python.org](https://www.python.org/downloads/). + +--- + +## Step 2 — Install Modpack Version Checker + +### Option A: pip (recommended) + +```bash +pip install modpack-version-checker +``` + +### Option B: Install with scheduler support + +```bash +pip install "modpack-version-checker[scheduler]" +``` + +This adds APScheduler for the `modpack-checker schedule` background daemon command. + +### Option C: Install from source + +```bash +git clone https://github.com/firefrostgaming/modpack-version-checker.git +cd modpack-version-checker +pip install -e ".[scheduler]" +``` + +--- + +## Step 3 — Get a CurseForge API Key (free) + +1. Go to [console.curseforge.com](https://console.curseforge.com) +2. Log in or create a free account +3. Click **Create API Key** +4. Copy the key + +--- + +## Step 4 — Configure + +```bash +modpack-checker config set-key YOUR_API_KEY_HERE +``` + +The key is stored in `~/.config/modpack-checker/config.json`. + +--- + +## Step 5 (Optional) — Set Up Discord Notifications + +1. In your Discord server, go to **Channel Settings → Integrations → Webhooks** +2. Click **New Webhook** and copy the URL +3. Run: + +```bash +modpack-checker config set-webhook https://discord.com/api/webhooks/YOUR_WEBHOOK_URL +``` + +A test message will be sent to confirm it works. + +--- + +## Step 6 — Add Your First Modpack + +Find your modpack's CurseForge project ID in the URL: +`https://www.curseforge.com/minecraft/modpacks/all-the-mods-9` → go to the page, the ID is in the sidebar. + +```bash +modpack-checker add 238222 # All The Mods 9 +modpack-checker add 361392 # RLCraft +``` + +--- + +## Step 7 — Check for Updates + +```bash +modpack-checker check +``` + +--- + +## Background Scheduler (Optional) + +Run continuous checks automatically: + +```bash +# Check every 6 hours (default) +modpack-checker schedule + +# Check every 12 hours +modpack-checker schedule --hours 12 +``` + +To run as a Linux systemd service, create `/etc/systemd/system/modpack-checker.service`: + +```ini +[Unit] +Description=Modpack Version Checker +After=network.target + +[Service] +Type=simple +User=YOUR_USERNAME +ExecStart=/usr/local/bin/modpack-checker schedule --hours 6 +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +Then: +```bash +sudo systemctl enable --now modpack-checker +``` + +--- + +## Uninstall + +```bash +pip uninstall modpack-version-checker + +# Remove config and database (optional) +rm -rf ~/.config/modpack-checker/ +``` + +--- + +## Troubleshooting + +**`modpack-checker: command not found`** +- Make sure pip's script directory is in your PATH +- Try: `python3 -m modpack_checker.cli` + +**`Invalid API key`** +- Double-check the key at [console.curseforge.com](https://console.curseforge.com) +- Ensure there are no extra spaces when setting it + +**`Connection failed`** +- Check your internet connection +- CurseForge API may be temporarily down; try again in a few minutes + +**`Modpack shows Unknown`** +- Verify the project ID is correct by checking the CurseForge page +- Some older modpacks have no files listed via the API diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/README.md b/docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/README.md new file mode 100644 index 0000000..979f9cd --- /dev/null +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/docs/README.md @@ -0,0 +1,91 @@ +# Modpack Version Checker + +**Monitor CurseForge modpack versions and get instantly notified when updates are released.** + +Stop manually checking CurseForge every day. Modpack Version Checker tracks your modpacks and fires a Discord alert the moment a new version drops — saving you 20+ minutes of daily maintenance. + +--- + +## Features + +- **Multi-modpack tracking** — watch as many packs as you need in a single database +- **Discord notifications** — rich embeds with old/new version info sent automatically +- **Version history** — full log of every check and what version was found +- **Per-modpack notification control** — silence specific packs without removing them +- **Built-in scheduler** — runs in the background and checks on a configurable interval +- **Manual override** — force a check any time with `modpack-checker check` +- **Graceful error handling** — API downtime shows clear messages, never crashes + +--- + +## Quick Start + +```bash +# 1. Install +pip install modpack-version-checker + +# 2. Set your CurseForge API key (free at console.curseforge.com) +modpack-checker config set-key YOUR_API_KEY + +# 3. Add a modpack (use its CurseForge project ID) +modpack-checker add 238222 # All The Mods 9 + +# 4. Check for updates +modpack-checker check +``` + +--- + +## Installation + +See [INSTALLATION.md](INSTALLATION.md) for full setup instructions including optional Discord notifications and background scheduling. + +--- + +## Commands + +| Command | Description | +|---|---| +| `modpack-checker add ` | Add a modpack to the watch list | +| `modpack-checker remove ` | Remove a modpack from the watch list | +| `modpack-checker list` | Show all watched modpacks and versions | +| `modpack-checker check` | Check all modpacks for updates now | +| `modpack-checker check --id ` | Check a single modpack | +| `modpack-checker status ` | Show detailed info + check history | +| `modpack-checker notifications --enable/--disable` | Toggle alerts per modpack | +| `modpack-checker schedule` | Start background scheduler | +| `modpack-checker config set-key ` | Save CurseForge API key | +| `modpack-checker config set-webhook ` | Save Discord webhook URL | +| `modpack-checker config set-interval ` | Set check interval | +| `modpack-checker config show` | Display current configuration | + +See [API.md](API.md) for full command reference with all flags. + +--- + +## Pricing + +| Tier | Price | Features | +|---|---|---| +| Standard | $9.99 | All features listed above | + +One-time purchase. No subscriptions. Available on [BuiltByBit](https://builtbybit.com). + +--- + +## Requirements + +- Python 3.9 or newer +- A free CurseForge API key ([get one here](https://console.curseforge.com)) +- Linux, macOS, or Windows + +--- + +## Support + +- Discord: [Firefrost Gaming Support Server] +- Response time: within 48 hours + +--- + +*Built by Firefrost Gaming* diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/requirements.txt b/docs/tasks/modpack-version-checker/code/modpack-version-checker/requirements.txt index ac01d92..86d48c1 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/requirements.txt +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/requirements.txt @@ -1 +1,16 @@ -# Placeholder - content in chat history +# Core runtime dependencies +requests>=2.28.0 +click>=8.1.0 +rich>=13.0.0 +pydantic>=2.0.0 +sqlalchemy>=2.0.0 + +# Optional: background scheduler +# apscheduler>=3.10.0 + +# Development / testing +# pytest>=7.0.0 +# pytest-cov>=4.0.0 +# pytest-mock>=3.10.0 +# responses>=0.23.0 +# black>=23.0.0 diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/setup.cfg b/docs/tasks/modpack-version-checker/code/modpack-version-checker/setup.cfg index ac01d92..d2e4956 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/setup.cfg +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/setup.cfg @@ -1 +1,15 @@ -# Placeholder - content in chat history +[tool:pytest] +testpaths = tests +pythonpath = src +addopts = --tb=short -q + +[coverage:run] +source = modpack_checker + +[coverage:report] +show_missing = True +skip_covered = False + +[flake8] +max-line-length = 100 +exclude = .git,__pycache__,build,dist diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/setup.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/setup.py index ac01d92..ae17b92 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/setup.py +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/setup.py @@ -1 +1,61 @@ -# Placeholder - content in chat history +"""Package setup for Modpack Version Checker.""" + +from pathlib import Path + +from setuptools import find_packages, setup + +long_description = (Path(__file__).parent / "docs" / "README.md").read_text( + encoding="utf-8" +) + +setup( + name="modpack-version-checker", + version="1.0.0", + author="Firefrost Gaming", + author_email="support@firefrostgaming.com", + description="Monitor CurseForge modpack versions and get notified of updates", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://firefrostgaming.com", + license="MIT", + packages=find_packages(where="src"), + package_dir={"": "src"}, + python_requires=">=3.9", + install_requires=[ + "requests>=2.28.0", + "click>=8.1.0", + "rich>=13.0.0", + "pydantic>=2.0.0", + "sqlalchemy>=2.0.0", + ], + extras_require={ + "scheduler": ["apscheduler>=3.10.0"], + "dev": [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "pytest-mock>=3.10.0", + "responses>=0.23.0", + "black>=23.0.0", + ], + }, + entry_points={ + "console_scripts": [ + "modpack-checker=modpack_checker.cli:main", + ], + }, + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Games/Entertainment", + "Topic :: System :: Monitoring", + ], + keywords="minecraft modpack curseforge version checker monitor", +) diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/cli.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/cli.py index ac01d92..42d18c8 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/cli.py +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/cli.py @@ -1 +1,565 @@ -# Placeholder - content in chat history +"""Click-based CLI — all user-facing commands.""" + +from __future__ import annotations + +import sys +from typing import Optional + +import click +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from . import __version__ +from .config import Config +from .curseforge import ( + CurseForgeAuthError, + CurseForgeClient, + CurseForgeError, + CurseForgeNotFoundError, +) +from .database import Database +from .notifier import DiscordNotifier, NotificationError + +console = Console() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _load_db(cfg: Config) -> Database: + return Database(cfg.database_path) + + +def _require_client(cfg: Config) -> CurseForgeClient: + """Return a configured API client, or exit with a helpful message.""" + if not cfg.curseforge_api_key: + console.print("[red]Error:[/red] No CurseForge API key configured.") + console.print( + "Set one with: [bold]modpack-checker config set-key YOUR_KEY[/bold]" + ) + console.print( + "Get a free key at: [dim]https://console.curseforge.com[/dim]" + ) + sys.exit(1) + return CurseForgeClient(cfg.curseforge_api_key) + + +# --------------------------------------------------------------------------- +# Root group +# --------------------------------------------------------------------------- + + +@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +@click.version_option(version=__version__, prog_name="modpack-checker") +def cli() -> None: + """Modpack Version Checker — monitor CurseForge modpacks for updates. + + \b + Quick start: + modpack-checker config set-key YOUR_CURSEFORGE_KEY + modpack-checker add 238222 + modpack-checker check + """ + + +# --------------------------------------------------------------------------- +# config sub-group +# --------------------------------------------------------------------------- + + +@cli.group("config") +def config_group() -> None: + """Manage configuration (API key, webhook URL, etc.).""" + + +@config_group.command("set-key") +@click.argument("api_key") +def config_set_key(api_key: str) -> None: + """Save your CurseForge API key and validate it.""" + cfg = Config.load() + cfg.curseforge_api_key = api_key.strip() + cfg.save() + + client = CurseForgeClient(api_key) + with console.status("Validating API key with CurseForge…"): + valid = client.validate_api_key() + + if valid: + console.print("[green]✓[/green] API key saved and validated.") + else: + console.print( + "[yellow]⚠[/yellow] API key saved, but validation failed. " + "Double-check at [dim]https://console.curseforge.com[/dim]" + ) + + +@config_group.command("set-webhook") +@click.argument("webhook_url") +def config_set_webhook(webhook_url: str) -> None: + """Save a Discord webhook URL and send a test message.""" + cfg = Config.load() + cfg.discord_webhook_url = webhook_url.strip() + cfg.save() + + notifier = DiscordNotifier(webhook_url) + try: + with console.status("Testing webhook…"): + notifier.test() + console.print("[green]✓[/green] Webhook saved and test message sent.") + except NotificationError as exc: + console.print(f"[yellow]⚠[/yellow] Webhook saved, but test failed: {exc}") + + +@config_group.command("set-interval") +@click.argument("hours", type=int) +def config_set_interval(hours: int) -> None: + """Set how often the scheduler checks for updates (in hours, 1–168).""" + if not 1 <= hours <= 168: + console.print("[red]Error:[/red] Interval must be between 1 and 168 hours.") + sys.exit(1) + cfg = Config.load() + cfg.check_interval_hours = hours + cfg.save() + console.print(f"[green]✓[/green] Check interval set to {hours} hour(s).") + + +@config_group.command("show") +def config_show() -> None: + """Display the current configuration.""" + cfg = Config.load() + + key = cfg.curseforge_api_key + if key and len(key) > 8: + masked = f"{key[:4]}{'*' * (len(key) - 8)}{key[-4:]}" + elif key: + masked = "****" + else: + masked = "[red]Not configured[/red]" + + table = Table(title="Configuration", box=box.ROUNDED, show_header=False) + table.add_column("Setting", style="cyan", min_width=22) + table.add_column("Value") + + table.add_row("CurseForge API Key", masked) + table.add_row( + "Discord Webhook", + cfg.discord_webhook_url or "[dim]Not configured[/dim]", + ) + table.add_row("Database", cfg.database_path) + table.add_row("Check Interval", f"{cfg.check_interval_hours} hour(s)") + table.add_row( + "Notifications", + "[green]On[/green]" if cfg.notification_on_update else "[red]Off[/red]", + ) + + console.print(table) + + +# --------------------------------------------------------------------------- +# add +# --------------------------------------------------------------------------- + + +@cli.command() +@click.argument("modpack_id", type=int) +def add(modpack_id: int) -> None: + """Add a modpack to the watch list by its CurseForge project ID. + + \b + Example: + modpack-checker add 238222 # All The Mods 9 + """ + cfg = Config.load() + client = _require_client(cfg) + db = _load_db(cfg) + + with console.status(f"Looking up modpack {modpack_id} on CurseForge…"): + try: + name = client.get_mod_name(modpack_id) + except CurseForgeNotFoundError: + console.print( + f"[red]Error:[/red] No modpack found with ID {modpack_id} on CurseForge." + ) + sys.exit(1) + except CurseForgeAuthError as exc: + console.print(f"[red]Auth error:[/red] {exc}") + sys.exit(1) + except CurseForgeError as exc: + console.print(f"[red]API error:[/red] {exc}") + sys.exit(1) + + try: + db.add_modpack(modpack_id, name) + except ValueError: + console.print( + f"[yellow]Already tracked:[/yellow] [bold]{name}[/bold] (ID: {modpack_id})" + ) + return + + console.print( + f"[green]✓[/green] Now tracking [bold]{name}[/bold] (ID: {modpack_id})" + ) + console.print("Run [bold]modpack-checker check[/bold] to fetch the current version.") + + +# --------------------------------------------------------------------------- +# remove +# --------------------------------------------------------------------------- + + +@cli.command() +@click.argument("modpack_id", type=int) +@click.confirmation_option(prompt="Remove this modpack and all its history?") +def remove(modpack_id: int) -> None: + """Remove a modpack from the watch list.""" + cfg = Config.load() + db = _load_db(cfg) + + modpack = db.get_modpack(modpack_id) + if modpack is None: + console.print( + f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list." + ) + sys.exit(1) + + db.remove_modpack(modpack_id) + console.print(f"[green]✓[/green] Removed [bold]{modpack.name}[/bold].") + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +@cli.command(name="list") +def list_modpacks() -> None: + """Show all watched modpacks and their last known versions.""" + cfg = Config.load() + db = _load_db(cfg) + modpacks = db.get_all_modpacks() + + if not modpacks: + console.print("[dim]Watch list is empty.[/dim]") + console.print( + "Add a modpack with: [bold]modpack-checker add [/bold]" + ) + return + + table = Table( + title=f"Watched Modpacks ({len(modpacks)})", + box=box.ROUNDED, + ) + table.add_column("CF ID", style="cyan", justify="right", no_wrap=True) + table.add_column("Name", style="bold white") + table.add_column("Current Version", style="green") + table.add_column("Last Checked", style="dim") + table.add_column("Alerts", justify="center") + + for mp in modpacks: + last_checked = ( + mp.last_checked.strftime("%Y-%m-%d %H:%M") + if mp.last_checked + else "[dim]Never[/dim]" + ) + alerts = "[green]✓[/green]" if mp.notification_enabled else "[red]✗[/red]" + version = mp.current_version or "[dim]—[/dim]" + table.add_row(str(mp.curseforge_id), mp.name, version, last_checked, alerts) + + console.print(table) + + +# --------------------------------------------------------------------------- +# check +# --------------------------------------------------------------------------- + + +@cli.command() +@click.option( + "--id", "-m", "modpack_id", type=int, default=None, + help="Check a single modpack by CurseForge ID.", +) +@click.option( + "--notify/--no-notify", default=True, + help="Send Discord notifications for updates found (default: on).", +) +def check(modpack_id: Optional[int], notify: bool) -> None: + """Check all (or one) watched modpack(s) for updates.""" + cfg = Config.load() + client = _require_client(cfg) + db = _load_db(cfg) + + if modpack_id is not None: + target = db.get_modpack(modpack_id) + if target is None: + console.print( + f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list." + ) + sys.exit(1) + modpacks = [target] + else: + modpacks = db.get_all_modpacks() + + if not modpacks: + console.print("[dim]No modpacks to check.[/dim]") + console.print( + "Add one with: [bold]modpack-checker add [/bold]" + ) + return + + notifier: Optional[DiscordNotifier] = None + if notify and cfg.discord_webhook_url and cfg.notification_on_update: + notifier = DiscordNotifier(cfg.discord_webhook_url) + + updates = 0 + errors = 0 + + for mp in modpacks: + with console.status(f"Checking [bold]{mp.name}[/bold]…"): + try: + file_obj = client.get_latest_file(mp.curseforge_id) + except CurseForgeNotFoundError: + console.print( + f" [red]✗[/red] {mp.name}: not found on CurseForge " + f"(ID: {mp.curseforge_id})" + ) + errors += 1 + continue + except CurseForgeError as exc: + console.print(f" [red]✗[/red] {mp.name}: API error — {exc}") + errors += 1 + continue + + if file_obj is None: + console.print(f" [yellow]⚠[/yellow] {mp.name}: no files found on CurseForge.") + errors += 1 + continue + + new_version = client.extract_version(file_obj) + notification_sent = False + + if mp.current_version == new_version: + line = f"[green]✓[/green] {mp.name}: up to date ([bold]{new_version}[/bold])" + elif mp.current_version is None: + line = ( + f"[cyan]→[/cyan] {mp.name}: " + f"initial version recorded as [bold]{new_version}[/bold]" + ) + else: + updates += 1 + line = ( + f"[yellow]↑[/yellow] {mp.name}: " + f"[dim]{mp.current_version}[/dim] → [bold green]{new_version}[/bold green]" + ) + if notifier and mp.notification_enabled: + try: + notifier.send_update( + mp.name, mp.curseforge_id, mp.current_version, new_version + ) + notification_sent = True + line += " [dim](notified)[/dim]" + except NotificationError as exc: + line += f" [red](notification failed: {exc})[/red]" + + db.update_version(mp.curseforge_id, new_version, notification_sent) + console.print(f" {line}") + + # Summary line + console.print() + parts = [] + if updates: + parts.append(f"[yellow]{updates} update(s) found[/yellow]") + if errors: + parts.append(f"[red]{errors} error(s)[/red]") + if not updates and not errors: + parts.append("[green]All modpacks are up to date.[/green]") + console.print(" ".join(parts)) + + +# --------------------------------------------------------------------------- +# status +# --------------------------------------------------------------------------- + + +@cli.command() +@click.argument("modpack_id", type=int) +@click.option("--limit", "-n", default=10, show_default=True, help="History entries to show.") +def status(modpack_id: int, limit: int) -> None: + """Show detailed status and check history for a modpack.""" + cfg = Config.load() + db = _load_db(cfg) + + mp = db.get_modpack(modpack_id) + if mp is None: + console.print( + f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list." + ) + sys.exit(1) + + last_checked = ( + mp.last_checked.strftime("%Y-%m-%d %H:%M UTC") if mp.last_checked else "Never" + ) + notif_str = "[green]Enabled[/green]" if mp.notification_enabled else "[red]Disabled[/red]" + + console.print( + Panel( + f"[bold white]{mp.name}[/bold white]\n" + f"CurseForge ID : [cyan]{mp.curseforge_id}[/cyan]\n" + f"Version : [green]{mp.current_version or 'Not checked yet'}[/green]\n" + f"Last Checked : [dim]{last_checked}[/dim]\n" + f"Notifications : {notif_str}", + title="Modpack Status", + border_style="cyan", + ) + ) + + history = db.get_check_history(modpack_id, limit=limit) + if not history: + console.print("[dim]No check history yet.[/dim]") + return + + table = Table(title=f"Check History (last {limit})", box=box.SIMPLE) + table.add_column("Timestamp", style="dim") + table.add_column("Version", style="green") + table.add_column("Notified", justify="center") + + for entry in history: + notified = "[green]✓[/green]" if entry.notification_sent else "[dim]—[/dim]" + table.add_row( + entry.checked_at.strftime("%Y-%m-%d %H:%M"), + entry.version_found or "[dim]Unknown[/dim]", + notified, + ) + + console.print(table) + + +# --------------------------------------------------------------------------- +# notifications (toggle per-modpack alerts) +# --------------------------------------------------------------------------- + + +@cli.command() +@click.argument("modpack_id", type=int) +@click.option( + "--enable/--disable", + default=True, + help="Enable or disable Discord alerts for this modpack.", +) +def notifications(modpack_id: int, enable: bool) -> None: + """Enable or disable Discord notifications for a specific modpack.""" + cfg = Config.load() + db = _load_db(cfg) + + mp = db.get_modpack(modpack_id) + if mp is None: + console.print( + f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list." + ) + sys.exit(1) + + db.toggle_notifications(modpack_id, enable) + state = "[green]enabled[/green]" if enable else "[red]disabled[/red]" + console.print( + f"[green]✓[/green] Notifications {state} for [bold]{mp.name}[/bold]." + ) + + +# --------------------------------------------------------------------------- +# schedule (background daemon) +# --------------------------------------------------------------------------- + + +@cli.command() +@click.option( + "--hours", "-h", "hours", type=int, default=None, + help="Override the configured check interval (hours).", +) +def schedule(hours: Optional[int]) -> None: + """Run continuous background checks on a configurable interval. + + Requires the [scheduler] extra: pip install modpack-version-checker[scheduler] + """ + try: + from apscheduler.schedulers.blocking import BlockingScheduler + except ImportError: + console.print("[red]Error:[/red] APScheduler is not installed.") + console.print( + "Install it with: [bold]pip install modpack-version-checker[scheduler][/bold]" + ) + sys.exit(1) + + cfg = Config.load() + interval = hours or cfg.check_interval_hours + + def _run_check() -> None: + """Inner function executed by the scheduler.""" + client = _require_client(cfg) + db = _load_db(cfg) + modpacks = db.get_all_modpacks() + + notifier: Optional[DiscordNotifier] = None + if cfg.discord_webhook_url and cfg.notification_on_update: + notifier = DiscordNotifier(cfg.discord_webhook_url) + + for mp in modpacks: + try: + file_obj = client.get_latest_file(mp.curseforge_id) + if file_obj is None: + continue + new_version = client.extract_version(file_obj) + notification_sent = False + + if ( + mp.current_version is not None + and mp.current_version != new_version + and notifier + and mp.notification_enabled + ): + try: + notifier.send_update( + mp.name, mp.curseforge_id, mp.current_version, new_version + ) + notification_sent = True + except NotificationError: + pass + + db.update_version(mp.curseforge_id, new_version, notification_sent) + except CurseForgeError: + pass # Log silently in daemon mode; don't crash the scheduler + + scheduler = BlockingScheduler() + scheduler.add_job(_run_check, "interval", hours=interval) + + console.print( + Panel( + f"Checking every [bold]{interval}[/bold] hour(s).\n" + "Press [bold]Ctrl-C[/bold] to stop.", + title="Modpack Checker — Scheduler Running", + border_style="green", + ) + ) + + # Run immediately so the user gets instant feedback + _run_check() + + try: + scheduler.start() + except KeyboardInterrupt: + console.print("\n[dim]Scheduler stopped.[/dim]") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + cli() + + +if __name__ == "__main__": + main() diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/config.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/config.py index 2555e2b..bfebf66 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/config.py +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/config.py @@ -31,6 +31,7 @@ class Config(BaseModel): data = json.load(f) return cls(**data) except (json.JSONDecodeError, ValueError): + # Corrupted config — fall back to defaults silently return cls() return cls() diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/curseforge.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/curseforge.py index ac01d92..32f48f3 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/curseforge.py +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/curseforge.py @@ -1 +1,192 @@ -# Placeholder - content in chat history +"""CurseForge API v1 client with error handling and retry logic.""" + +from __future__ import annotations + +import time +from typing import Any, Dict, Optional + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + + +# --------------------------------------------------------------------------- +# Custom exceptions +# --------------------------------------------------------------------------- + + +class CurseForgeError(Exception): + """Base exception for all CurseForge API errors.""" + + +class CurseForgeAuthError(CurseForgeError): + """API key is missing, invalid, or lacks required permissions.""" + + +class CurseForgeNotFoundError(CurseForgeError): + """The requested modpack or file does not exist.""" + + +class CurseForgeRateLimitError(CurseForgeError): + """The API rate limit has been exceeded (429).""" + + +# --------------------------------------------------------------------------- +# Client +# --------------------------------------------------------------------------- + + +class CurseForgeClient: + """Thin wrapper around the CurseForge REST API v1. + + Handles authentication, retries on transient errors, and translates + HTTP status codes into typed exceptions so callers never see raw + requests exceptions. + """ + + BASE_URL = "https://api.curseforge.com" + _MINECRAFT_GAME_ID = 432 # used for API key validation + + def __init__(self, api_key: str, timeout: int = 15) -> None: + self.api_key = api_key + self.timeout = timeout + + self._session = requests.Session() + self._session.headers.update( + { + "x-api-key": api_key, + "Accept": "application/json", + "User-Agent": "ModpackVersionChecker/1.0 (Firefrost Gaming)", + } + ) + + # Retry on connection errors and server-side 5xx — never on 4xx + retry = Retry( + total=3, + backoff_factor=1.0, + status_forcelist=[500, 502, 503, 504], + allowed_methods=["GET"], + raise_on_status=False, + ) + self._session.mount("https://", HTTPAdapter(max_retries=retry)) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any: + """Perform a GET request and return parsed JSON. + + Raises: + CurseForgeAuthError: on 401 / 403 + CurseForgeNotFoundError: on 404 + CurseForgeRateLimitError: on 429 + CurseForgeError: on connection failure, timeout, or other HTTP error + """ + url = f"{self.BASE_URL}{path}" + try: + response = self._session.get(url, params=params, timeout=self.timeout) + except requests.ConnectionError as exc: + raise CurseForgeError( + f"Could not connect to CurseForge API. Check your internet connection." + ) from exc + except requests.Timeout: + raise CurseForgeError( + f"CurseForge API timed out after {self.timeout}s. Try again later." + ) + + if response.status_code == 401: + raise CurseForgeAuthError( + "Invalid API key. Verify your key at https://console.curseforge.com" + ) + if response.status_code == 403: + raise CurseForgeAuthError( + "API key lacks permission for this resource. " + "Ensure your key has 'Mods' read access." + ) + if response.status_code == 404: + raise CurseForgeNotFoundError(f"Resource not found: {path}") + if response.status_code == 429: + raise CurseForgeRateLimitError( + "CurseForge rate limit exceeded (100 req/min). " + "Wait a moment and try again." + ) + + try: + response.raise_for_status() + except requests.HTTPError as exc: + raise CurseForgeError( + f"Unexpected API error ({response.status_code}): {response.text[:200]}" + ) from exc + + return response.json() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def validate_api_key(self) -> bool: + """Return True if the current API key is valid.""" + try: + self._get(f"/v1/games/{self._MINECRAFT_GAME_ID}") + return True + except (CurseForgeAuthError, CurseForgeError): + return False + + def get_mod(self, mod_id: int) -> Dict[str, Any]: + """Fetch full modpack metadata by CurseForge project ID. + + Raises: + CurseForgeNotFoundError: if the project ID does not exist. + """ + data = self._get(f"/v1/mods/{mod_id}") + return data["data"] + + def get_mod_name(self, mod_id: int) -> str: + """Return the display name for a modpack.""" + mod = self.get_mod(mod_id) + return mod.get("name", f"Modpack {mod_id}") + + def get_latest_file(self, mod_id: int) -> Optional[Dict[str, Any]]: + """Return the most recent file object for a modpack. + + Prefers the ``latestFiles`` field on the mod object (single request). + Falls back to querying the files endpoint if that is empty. + + Returns None if no files are found. + """ + mod = self.get_mod(mod_id) + + # Fast path: latestFiles is populated by CurseForge automatically + latest_files = mod.get("latestFiles", []) + if latest_files: + # Sort by fileDate descending to get the newest release + latest_files_sorted = sorted( + latest_files, + key=lambda f: f.get("fileDate", ""), + reverse=True, + ) + return latest_files_sorted[0] + + # Slow path: query the files endpoint + data = self._get( + f"/v1/mods/{mod_id}/files", + params={"pageSize": 1, "sortOrder": "desc"}, + ) + files = data.get("data", []) + return files[0] if files else None + + def extract_version(self, file_obj: Dict[str, Any]) -> str: + """Extract a human-readable version string from a CurseForge file object. + + Priority: displayName → fileName → file ID fallback. + """ + display_name = (file_obj.get("displayName") or "").strip() + if display_name: + return display_name + + file_name = (file_obj.get("fileName") or "").strip() + if file_name: + return file_name + + return f"File ID {file_obj.get('id', 'unknown')}" diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/database.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/database.py index ac01d92..265e204 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/database.py +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/database.py @@ -1 +1,225 @@ -# Placeholder - content in chat history +"""SQLite database layer using SQLAlchemy 2.0 ORM.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, create_engine, select +from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship + + +# --------------------------------------------------------------------------- +# ORM models (internal — not exposed outside this module) +# --------------------------------------------------------------------------- + + +class _Base(DeclarativeBase): + pass + + +class _ModpackRow(_Base): + __tablename__ = "modpacks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + curseforge_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + current_version: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + last_checked: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + notification_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + history: Mapped[List["_CheckHistoryRow"]] = relationship( + back_populates="modpack", cascade="all, delete-orphan" + ) + + +class _CheckHistoryRow(_Base): + __tablename__ = "check_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + modpack_id: Mapped[int] = mapped_column(ForeignKey("modpacks.id"), nullable=False) + checked_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + version_found: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + notification_sent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + modpack: Mapped["_ModpackRow"] = relationship(back_populates="history") + + +# --------------------------------------------------------------------------- +# Public dataclasses (detached, safe to use outside a session) +# --------------------------------------------------------------------------- + + +@dataclass +class Modpack: + """Read-only snapshot of a tracked modpack.""" + + id: int + curseforge_id: int + name: str + current_version: Optional[str] + last_checked: Optional[datetime] + notification_enabled: bool + + @classmethod + def _from_row(cls, row: _ModpackRow) -> "Modpack": + return cls( + id=row.id, + curseforge_id=row.curseforge_id, + name=row.name, + current_version=row.current_version, + last_checked=row.last_checked, + notification_enabled=row.notification_enabled, + ) + + +@dataclass +class CheckHistory: + """Read-only snapshot of a single version-check record.""" + + id: int + modpack_id: int + checked_at: datetime + version_found: Optional[str] + notification_sent: bool + + @classmethod + def _from_row(cls, row: _CheckHistoryRow) -> "CheckHistory": + return cls( + id=row.id, + modpack_id=row.modpack_id, + checked_at=row.checked_at, + version_found=row.version_found, + notification_sent=row.notification_sent, + ) + + +# --------------------------------------------------------------------------- +# Database façade +# --------------------------------------------------------------------------- + + +class Database: + """All database operations for the modpack tracker.""" + + def __init__(self, db_path: str) -> None: + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + self.engine = create_engine(f"sqlite:///{db_path}", echo=False) + _Base.metadata.create_all(self.engine) + + # --- write operations --------------------------------------------------- + + def add_modpack(self, curseforge_id: int, name: str) -> Modpack: + """Add a new modpack to the watch list. + + Raises: + ValueError: if the modpack is already being tracked. + """ + with Session(self.engine) as session: + existing = session.scalar( + select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id) + ) + if existing is not None: + raise ValueError( + f"Modpack ID {curseforge_id} is already being tracked." + ) + row = _ModpackRow( + curseforge_id=curseforge_id, + name=name, + notification_enabled=True, + ) + session.add(row) + session.commit() + return Modpack._from_row(row) + + def remove_modpack(self, curseforge_id: int) -> bool: + """Remove a modpack and its history. Returns False if not found.""" + with Session(self.engine) as session: + row = session.scalar( + select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id) + ) + if row is None: + return False + session.delete(row) + session.commit() + return True + + def update_version( + self, + curseforge_id: int, + version: str, + notification_sent: bool = False, + ) -> None: + """Record a new version check result, updating the cached version. + + Raises: + ValueError: if the modpack is not in the database. + """ + with Session(self.engine) as session: + row = session.scalar( + select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id) + ) + if row is None: + raise ValueError(f"Modpack {curseforge_id} not found in database.") + row.current_version = version + row.last_checked = datetime.utcnow() + session.add( + _CheckHistoryRow( + modpack_id=row.id, + checked_at=datetime.utcnow(), + version_found=version, + notification_sent=notification_sent, + ) + ) + session.commit() + + def toggle_notifications(self, curseforge_id: int, enabled: bool) -> bool: + """Enable or disable Discord notifications for a modpack. + + Returns False if modpack not found. + """ + with Session(self.engine) as session: + row = session.scalar( + select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id) + ) + if row is None: + return False + row.notification_enabled = enabled + session.commit() + return True + + # --- read operations ---------------------------------------------------- + + def get_modpack(self, curseforge_id: int) -> Optional[Modpack]: + """Return a single modpack, or None if not tracked.""" + with Session(self.engine) as session: + row = session.scalar( + select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id) + ) + return Modpack._from_row(row) if row else None + + def get_all_modpacks(self) -> List[Modpack]: + """Return all tracked modpacks.""" + with Session(self.engine) as session: + rows = session.scalars(select(_ModpackRow)).all() + return [Modpack._from_row(r) for r in rows] + + def get_check_history( + self, curseforge_id: int, limit: int = 10 + ) -> List[CheckHistory]: + """Return recent check history for a modpack, newest first.""" + with Session(self.engine) as session: + modpack_row = session.scalar( + select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id) + ) + if modpack_row is None: + return [] + rows = session.scalars( + select(_CheckHistoryRow) + .where(_CheckHistoryRow.modpack_id == modpack_row.id) + .order_by(_CheckHistoryRow.checked_at.desc()) + .limit(limit) + ).all() + return [CheckHistory._from_row(r) for r in rows] diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/notifier.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/notifier.py index ac01d92..ef3135b 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/notifier.py +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/src/modpack_checker/notifier.py @@ -1 +1,122 @@ -# Placeholder - content in chat history +"""Notification delivery — Discord webhooks and generic HTTP webhooks.""" + +from __future__ import annotations + +from typing import Optional + +import requests + + +class NotificationError(Exception): + """Raised when a notification cannot be delivered.""" + + +class DiscordNotifier: + """Send modpack update alerts to a Discord channel via webhook. + + Embeds are used so the messages look polished without additional + configuration on the user's side. + """ + + _EMBED_COLOR_UPDATE = 0xF5A623 # amber — update available + _EMBED_COLOR_TEST = 0x43B581 # green — test / OK + + def __init__(self, webhook_url: str, timeout: int = 10) -> None: + self.webhook_url = webhook_url + self.timeout = timeout + + # ------------------------------------------------------------------ + # Public helpers + # ------------------------------------------------------------------ + + def send_update( + self, + modpack_name: str, + curseforge_id: int, + old_version: Optional[str], + new_version: str, + ) -> None: + """Post an update-available embed to Discord. + + Raises: + NotificationError: if the webhook request fails. + """ + embed = { + "title": "Modpack Update Available", + "color": self._EMBED_COLOR_UPDATE, + "url": f"https://www.curseforge.com/minecraft/modpacks/{curseforge_id}", + "fields": [ + { + "name": "Modpack", + "value": modpack_name, + "inline": True, + }, + { + "name": "CurseForge ID", + "value": str(curseforge_id), + "inline": True, + }, + { + "name": "Previous Version", + "value": old_version or "Unknown", + "inline": False, + }, + { + "name": "New Version", + "value": new_version, + "inline": False, + }, + ], + "footer": { + "text": "Modpack Version Checker \u2022 Firefrost Gaming", + }, + } + self._post({"embeds": [embed]}) + + def test(self) -> None: + """Send a simple test message to verify the webhook URL works. + + Raises: + NotificationError: if the request fails. + """ + self._post( + { + "embeds": [ + { + "title": "Webhook Connected", + "description": ( + "Modpack Version Checker notifications are working correctly." + ), + "color": self._EMBED_COLOR_TEST, + "footer": {"text": "Modpack Version Checker \u2022 Firefrost Gaming"}, + } + ] + } + ) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _post(self, payload: dict) -> None: + try: + response = requests.post( + self.webhook_url, + json=payload, + timeout=self.timeout, + ) + # Discord returns 204 No Content on success + if response.status_code not in (200, 204): + raise NotificationError( + f"Discord returned HTTP {response.status_code}: {response.text[:200]}" + ) + except requests.ConnectionError as exc: + raise NotificationError( + "Could not reach Discord. Check your internet connection." + ) from exc + except requests.Timeout: + raise NotificationError( + f"Discord webhook timed out after {self.timeout}s." + ) + except requests.RequestException as exc: + raise NotificationError(f"Webhook request failed: {exc}") from exc diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/__init__.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/conftest.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/conftest.py index ac01d92..6f8f8d6 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/conftest.py +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/conftest.py @@ -1 +1,11 @@ -# Placeholder - content in chat history +"""Shared pytest fixtures.""" + +import pytest + +from modpack_checker.database import Database + + +@pytest.fixture +def db(tmp_path): + """In-memory-equivalent SQLite database backed by a temp directory.""" + return Database(str(tmp_path / "test.db")) diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_cli.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_cli.py new file mode 100644 index 0000000..f8242ee --- /dev/null +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_cli.py @@ -0,0 +1,339 @@ +"""Tests for cli.py using Click's test runner.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +import responses as responses_lib +from click.testing import CliRunner + +from modpack_checker.cli import cli +from modpack_checker.curseforge import CurseForgeNotFoundError + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def mock_cfg(tmp_path): + """Return a Config-like mock backed by a real temp database.""" + cfg = MagicMock() + cfg.curseforge_api_key = "test-api-key" + cfg.database_path = str(tmp_path / "test.db") + cfg.discord_webhook_url = None + cfg.notification_on_update = True + cfg.check_interval_hours = 6 + return cfg + + +# --------------------------------------------------------------------------- +# Root / help +# --------------------------------------------------------------------------- + + +def test_help(runner): + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Modpack Version Checker" in result.output + + +def test_version(runner): + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + assert "1.0.0" in result.output + + +# --------------------------------------------------------------------------- +# config show +# --------------------------------------------------------------------------- + + +def test_config_show(runner, mock_cfg): + mock_cfg.curseforge_api_key = "abcd1234efgh5678" + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["config", "show"]) + assert result.exit_code == 0 + assert "abcd" in result.output # first 4 chars + assert "5678" in result.output # last 4 chars + + +def test_config_show_no_key(runner, mock_cfg): + mock_cfg.curseforge_api_key = "" + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["config", "show"]) + assert result.exit_code == 0 + assert "Not configured" in result.output + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +def test_list_empty(runner, mock_cfg): + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["list"]) + assert result.exit_code == 0 + assert "empty" in result.output.lower() + + +def test_list_with_modpacks(runner, mock_cfg, tmp_path): + from modpack_checker.database import Database + db = Database(str(tmp_path / "test.db")) + db.add_modpack(111, "Pack Alpha") + db.add_modpack(222, "Pack Beta") + + mock_cfg.database_path = str(tmp_path / "test.db") + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["list"]) + + assert result.exit_code == 0 + assert "Pack Alpha" in result.output + assert "Pack Beta" in result.output + + +# --------------------------------------------------------------------------- +# add +# --------------------------------------------------------------------------- + + +def test_add_requires_api_key(runner, mock_cfg): + mock_cfg.curseforge_api_key = "" + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["add", "12345"]) + assert result.exit_code == 1 + assert "API key" in result.output + + +@responses_lib.activate +def test_add_success(runner, mock_cfg): + responses_lib.add( + responses_lib.GET, + "https://api.curseforge.com/v1/mods/12345", + json={"data": {"id": 12345, "name": "Test Modpack", "latestFiles": []}}, + status=200, + ) + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["add", "12345"]) + assert result.exit_code == 0 + assert "Test Modpack" in result.output + assert "tracking" in result.output.lower() + + +@responses_lib.activate +def test_add_not_found(runner, mock_cfg): + responses_lib.add( + responses_lib.GET, + "https://api.curseforge.com/v1/mods/99999", + status=404, + ) + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["add", "99999"]) + assert result.exit_code == 1 + assert "99999" in result.output + + +@responses_lib.activate +def test_add_duplicate_shows_warning(runner, mock_cfg, tmp_path): + responses_lib.add( + responses_lib.GET, + "https://api.curseforge.com/v1/mods/12345", + json={"data": {"id": 12345, "name": "Test Modpack", "latestFiles": []}}, + status=200, + ) + from modpack_checker.database import Database + db = Database(str(tmp_path / "test.db")) + db.add_modpack(12345, "Test Modpack") + + mock_cfg.database_path = str(tmp_path / "test.db") + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["add", "12345"]) + assert "already tracked" in result.output.lower() + + +# --------------------------------------------------------------------------- +# remove +# --------------------------------------------------------------------------- + + +def test_remove_not_in_list(runner, mock_cfg): + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["remove", "99999"], input="y\n") + assert result.exit_code == 1 + assert "not in your watch list" in result.output + + +def test_remove_success(runner, mock_cfg, tmp_path): + from modpack_checker.database import Database + db = Database(str(tmp_path / "test.db")) + db.add_modpack(12345, "Test Pack") + + mock_cfg.database_path = str(tmp_path / "test.db") + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["remove", "12345"], input="y\n") + assert result.exit_code == 0 + assert "Removed" in result.output + + +def test_remove_aborted(runner, mock_cfg, tmp_path): + from modpack_checker.database import Database + db = Database(str(tmp_path / "test.db")) + db.add_modpack(12345, "Test Pack") + + mock_cfg.database_path = str(tmp_path / "test.db") + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["remove", "12345"], input="n\n") + # Aborted — pack should still exist + assert db.get_modpack(12345) is not None + + +# --------------------------------------------------------------------------- +# check +# --------------------------------------------------------------------------- + + +def test_check_empty_list(runner, mock_cfg): + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["check"]) + assert result.exit_code == 0 + assert "No modpacks" in result.output + + +@responses_lib.activate +def test_check_up_to_date(runner, mock_cfg, tmp_path): + from modpack_checker.database import Database + db = Database(str(tmp_path / "test.db")) + db.add_modpack(12345, "Test Pack") + db.update_version(12345, "1.0.0") + + responses_lib.add( + responses_lib.GET, + "https://api.curseforge.com/v1/mods/12345", + json={ + "data": { + "id": 12345, + "name": "Test Pack", + "latestFiles": [ + { + "id": 1, + "displayName": "1.0.0", + "fileName": "pack-1.0.0.zip", + "fileDate": "2026-01-01T00:00:00Z", + } + ], + } + }, + status=200, + ) + + mock_cfg.database_path = str(tmp_path / "test.db") + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["check", "--no-notify"]) + + assert result.exit_code == 0 + assert "up to date" in result.output.lower() + + +@responses_lib.activate +def test_check_update_available(runner, mock_cfg, tmp_path): + from modpack_checker.database import Database + db = Database(str(tmp_path / "test.db")) + db.add_modpack(12345, "Test Pack") + db.update_version(12345, "1.0.0") + + responses_lib.add( + responses_lib.GET, + "https://api.curseforge.com/v1/mods/12345", + json={ + "data": { + "id": 12345, + "name": "Test Pack", + "latestFiles": [ + { + "id": 2, + "displayName": "1.1.0", + "fileName": "pack-1.1.0.zip", + "fileDate": "2026-02-01T00:00:00Z", + } + ], + } + }, + status=200, + ) + + mock_cfg.database_path = str(tmp_path / "test.db") + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["check", "--no-notify"]) + + assert result.exit_code == 0 + assert "1.1.0" in result.output + + +# --------------------------------------------------------------------------- +# status +# --------------------------------------------------------------------------- + + +def test_status_not_found(runner, mock_cfg): + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["status", "99999"]) + assert result.exit_code == 1 + assert "not in your watch list" in result.output + + +def test_status_shows_info(runner, mock_cfg, tmp_path): + from modpack_checker.database import Database + db = Database(str(tmp_path / "test.db")) + db.add_modpack(12345, "Test Pack") + db.update_version(12345, "2.0.0") + + mock_cfg.database_path = str(tmp_path / "test.db") + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["status", "12345"]) + + assert result.exit_code == 0 + assert "Test Pack" in result.output + assert "2.0.0" in result.output + + +# --------------------------------------------------------------------------- +# notifications command +# --------------------------------------------------------------------------- + + +def test_notifications_disable(runner, mock_cfg, tmp_path): + from modpack_checker.database import Database + db = Database(str(tmp_path / "test.db")) + db.add_modpack(12345, "Test Pack") + + mock_cfg.database_path = str(tmp_path / "test.db") + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["notifications", "12345", "--disable"]) + + assert result.exit_code == 0 + assert "disabled" in result.output + assert db.get_modpack(12345).notification_enabled is False + + +def test_notifications_enable(runner, mock_cfg, tmp_path): + from modpack_checker.database import Database + db = Database(str(tmp_path / "test.db")) + db.add_modpack(12345, "Test Pack") + db.toggle_notifications(12345, False) + + mock_cfg.database_path = str(tmp_path / "test.db") + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["notifications", "12345", "--enable"]) + + assert result.exit_code == 0 + assert "enabled" in result.output + assert db.get_modpack(12345).notification_enabled is True + + +def test_notifications_missing_modpack(runner, mock_cfg): + with patch("modpack_checker.cli.Config.load", return_value=mock_cfg): + result = runner.invoke(cli, ["notifications", "99999", "--enable"]) + assert result.exit_code == 1 diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_config.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_config.py index ac01d92..2cf9ca6 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_config.py +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_config.py @@ -1 +1,72 @@ -# Placeholder - content in chat history +"""Tests for config.py.""" + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from modpack_checker.config import Config + + +def test_default_values(): + cfg = Config() + assert cfg.curseforge_api_key == "" + assert cfg.discord_webhook_url is None + assert cfg.check_interval_hours == 6 + assert cfg.notification_on_update is True + + +def test_is_configured_without_key(): + assert Config().is_configured() is False + + +def test_is_configured_with_key(): + assert Config(curseforge_api_key="abc123").is_configured() is True + + +def test_save_and_load_round_trip(tmp_path): + config_dir = tmp_path / ".config" / "modpack-checker" + config_file = config_dir / "config.json" + + with patch("modpack_checker.config.CONFIG_DIR", config_dir), \ + patch("modpack_checker.config.CONFIG_FILE", config_file): + original = Config( + curseforge_api_key="test-key-xyz", + check_interval_hours=12, + notification_on_update=False, + ) + original.save() + + loaded = Config.load() + + assert loaded.curseforge_api_key == "test-key-xyz" + assert loaded.check_interval_hours == 12 + assert loaded.notification_on_update is False + + +def test_load_returns_defaults_when_file_missing(tmp_path): + config_file = tmp_path / "nonexistent.json" + with patch("modpack_checker.config.CONFIG_FILE", config_file): + cfg = Config.load() + assert cfg.curseforge_api_key == "" + + +def test_load_returns_defaults_on_corrupted_file(tmp_path): + config_dir = tmp_path / ".config" / "modpack-checker" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.json" + config_file.write_text("{ this is not valid json }") + + with patch("modpack_checker.config.CONFIG_DIR", config_dir), \ + patch("modpack_checker.config.CONFIG_FILE", config_file): + cfg = Config.load() + + assert cfg.curseforge_api_key == "" + + +def test_interval_bounds(): + with pytest.raises(Exception): + Config(check_interval_hours=0) + with pytest.raises(Exception): + Config(check_interval_hours=169) diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_curseforge.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_curseforge.py index ac01d92..4633118 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_curseforge.py +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_curseforge.py @@ -1 +1,227 @@ -# Placeholder - content in chat history +"""Tests for curseforge.py.""" + +import pytest +import responses as responses_lib + +from modpack_checker.curseforge import ( + CurseForgeAuthError, + CurseForgeClient, + CurseForgeError, + CurseForgeNotFoundError, + CurseForgeRateLimitError, +) + +BASE = "https://api.curseforge.com" + + +@pytest.fixture +def client(): + return CurseForgeClient("test-api-key", timeout=5) + + +# --------------------------------------------------------------------------- +# get_mod +# --------------------------------------------------------------------------- + + +@responses_lib.activate +def test_get_mod_success(client): + responses_lib.add( + responses_lib.GET, + f"{BASE}/v1/mods/123456", + json={"data": {"id": 123456, "name": "Test Pack", "latestFiles": []}}, + status=200, + ) + mod = client.get_mod(123456) + assert mod["name"] == "Test Pack" + + +@responses_lib.activate +def test_get_mod_not_found(client): + responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/999", status=404) + with pytest.raises(CurseForgeNotFoundError): + client.get_mod(999) + + +@responses_lib.activate +def test_get_mod_invalid_key_401(client): + responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=401) + with pytest.raises(CurseForgeAuthError, match="Invalid API key"): + client.get_mod(123) + + +@responses_lib.activate +def test_get_mod_forbidden_403(client): + responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=403) + with pytest.raises(CurseForgeAuthError, match="permission"): + client.get_mod(123) + + +@responses_lib.activate +def test_get_mod_rate_limit_429(client): + responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=429) + with pytest.raises(CurseForgeRateLimitError): + client.get_mod(123) + + +@responses_lib.activate +def test_get_mod_server_error(client): + # responses library doesn't retry by default in tests; just test the exception + responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500) + responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500) + responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500) + responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500) + with pytest.raises(CurseForgeError): + client.get_mod(123) + + +# --------------------------------------------------------------------------- +# get_mod_name +# --------------------------------------------------------------------------- + + +@responses_lib.activate +def test_get_mod_name(client): + responses_lib.add( + responses_lib.GET, + f"{BASE}/v1/mods/100", + json={"data": {"id": 100, "name": "All The Mods 9", "latestFiles": []}}, + status=200, + ) + assert client.get_mod_name(100) == "All The Mods 9" + + +@responses_lib.activate +def test_get_mod_name_fallback(client): + """If 'name' key is missing, return generic fallback.""" + responses_lib.add( + responses_lib.GET, + f"{BASE}/v1/mods/100", + json={"data": {"id": 100, "latestFiles": []}}, + status=200, + ) + assert client.get_mod_name(100) == "Modpack 100" + + +# --------------------------------------------------------------------------- +# get_latest_file +# --------------------------------------------------------------------------- + + +@responses_lib.activate +def test_get_latest_file_uses_latest_files(client): + """get_latest_file should prefer the latestFiles field (fast path).""" + responses_lib.add( + responses_lib.GET, + f"{BASE}/v1/mods/200", + json={ + "data": { + "id": 200, + "name": "Pack", + "latestFiles": [ + { + "id": 9001, + "displayName": "Pack 2.0.0", + "fileName": "pack-2.0.0.zip", + "fileDate": "2026-01-15T00:00:00Z", + }, + { + "id": 9000, + "displayName": "Pack 1.0.0", + "fileName": "pack-1.0.0.zip", + "fileDate": "2025-12-01T00:00:00Z", + }, + ], + } + }, + status=200, + ) + file_obj = client.get_latest_file(200) + assert file_obj["displayName"] == "Pack 2.0.0" + + +@responses_lib.activate +def test_get_latest_file_fallback_files_endpoint(client): + """Falls back to the /files endpoint when latestFiles is empty.""" + responses_lib.add( + responses_lib.GET, + f"{BASE}/v1/mods/300", + json={"data": {"id": 300, "name": "Pack", "latestFiles": []}}, + status=200, + ) + responses_lib.add( + responses_lib.GET, + f"{BASE}/v1/mods/300/files", + json={ + "data": [ + {"id": 8000, "displayName": "Pack 3.0.0", "fileName": "pack-3.0.0.zip"} + ] + }, + status=200, + ) + file_obj = client.get_latest_file(300) + assert file_obj["displayName"] == "Pack 3.0.0" + + +@responses_lib.activate +def test_get_latest_file_no_files_returns_none(client): + responses_lib.add( + responses_lib.GET, + f"{BASE}/v1/mods/400", + json={"data": {"id": 400, "name": "Pack", "latestFiles": []}}, + status=200, + ) + responses_lib.add( + responses_lib.GET, + f"{BASE}/v1/mods/400/files", + json={"data": []}, + status=200, + ) + assert client.get_latest_file(400) is None + + +# --------------------------------------------------------------------------- +# extract_version +# --------------------------------------------------------------------------- + + +def test_extract_version_prefers_display_name(client): + file_obj = {"displayName": "ATM9 1.2.3", "fileName": "atm9-1.2.3.zip", "id": 1} + assert client.extract_version(file_obj) == "ATM9 1.2.3" + + +def test_extract_version_falls_back_to_filename(client): + file_obj = {"displayName": "", "fileName": "pack-1.0.0.zip", "id": 1} + assert client.extract_version(file_obj) == "pack-1.0.0.zip" + + +def test_extract_version_last_resort_file_id(client): + file_obj = {"displayName": "", "fileName": "", "id": 9999} + assert client.extract_version(file_obj) == "File ID 9999" + + +def test_extract_version_strips_whitespace(client): + file_obj = {"displayName": " Pack 1.0 ", "fileName": "pack.zip", "id": 1} + assert client.extract_version(file_obj) == "Pack 1.0" + + +# --------------------------------------------------------------------------- +# validate_api_key +# --------------------------------------------------------------------------- + + +@responses_lib.activate +def test_validate_api_key_success(client): + responses_lib.add( + responses_lib.GET, + f"{BASE}/v1/games/432", + json={"data": {"id": 432, "name": "Minecraft"}}, + status=200, + ) + assert client.validate_api_key() is True + + +@responses_lib.activate +def test_validate_api_key_failure(client): + responses_lib.add(responses_lib.GET, f"{BASE}/v1/games/432", status=401) + assert client.validate_api_key() is False diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_database.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_database.py index ac01d92..7b7d239 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_database.py +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_database.py @@ -1 +1,174 @@ -# Placeholder - content in chat history +"""Tests for database.py.""" + +from datetime import datetime + +import pytest + +from modpack_checker.database import Database + + +# --------------------------------------------------------------------------- +# add_modpack +# --------------------------------------------------------------------------- + + +def test_add_modpack_returns_correct_fields(db): + mp = db.add_modpack(12345, "Test Pack") + assert mp.curseforge_id == 12345 + assert mp.name == "Test Pack" + assert mp.current_version is None + assert mp.last_checked is None + assert mp.notification_enabled is True + + +def test_add_modpack_duplicate_raises(db): + db.add_modpack(12345, "Test Pack") + with pytest.raises(ValueError, match="already being tracked"): + db.add_modpack(12345, "Test Pack Again") + + +# --------------------------------------------------------------------------- +# remove_modpack +# --------------------------------------------------------------------------- + + +def test_remove_modpack_returns_true(db): + db.add_modpack(12345, "Test Pack") + assert db.remove_modpack(12345) is True + + +def test_remove_modpack_missing_returns_false(db): + assert db.remove_modpack(99999) is False + + +def test_remove_modpack_deletes_record(db): + db.add_modpack(12345, "Test Pack") + db.remove_modpack(12345) + assert db.get_modpack(12345) is None + + +# --------------------------------------------------------------------------- +# get_modpack +# --------------------------------------------------------------------------- + + +def test_get_modpack_found(db): + db.add_modpack(111, "Pack A") + mp = db.get_modpack(111) + assert mp is not None + assert mp.name == "Pack A" + + +def test_get_modpack_not_found(db): + assert db.get_modpack(999) is None + + +# --------------------------------------------------------------------------- +# get_all_modpacks +# --------------------------------------------------------------------------- + + +def test_get_all_modpacks_empty(db): + assert db.get_all_modpacks() == [] + + +def test_get_all_modpacks_multiple(db): + db.add_modpack(1, "Pack A") + db.add_modpack(2, "Pack B") + db.add_modpack(3, "Pack C") + modpacks = db.get_all_modpacks() + assert len(modpacks) == 3 + ids = {mp.curseforge_id for mp in modpacks} + assert ids == {1, 2, 3} + + +# --------------------------------------------------------------------------- +# update_version +# --------------------------------------------------------------------------- + + +def test_update_version_sets_fields(db): + db.add_modpack(12345, "Test Pack") + db.update_version(12345, "1.2.3", notification_sent=True) + mp = db.get_modpack(12345) + assert mp.current_version == "1.2.3" + assert mp.last_checked is not None + + +def test_update_version_nonexistent_raises(db): + with pytest.raises(ValueError, match="not found"): + db.update_version(99999, "1.0.0") + + +def test_update_version_multiple_times(db): + db.add_modpack(12345, "Test Pack") + db.update_version(12345, "1.0.0") + db.update_version(12345, "1.1.0") + mp = db.get_modpack(12345) + assert mp.current_version == "1.1.0" + + +# --------------------------------------------------------------------------- +# get_check_history +# --------------------------------------------------------------------------- + + +def test_check_history_newest_first(db): + db.add_modpack(12345, "Test Pack") + db.update_version(12345, "1.0.0", notification_sent=False) + db.update_version(12345, "1.1.0", notification_sent=True) + history = db.get_check_history(12345, limit=10) + assert len(history) == 2 + assert history[0].version_found == "1.1.0" + assert history[0].notification_sent is True + assert history[1].version_found == "1.0.0" + + +def test_check_history_limit(db): + db.add_modpack(12345, "Test Pack") + for i in range(5): + db.update_version(12345, f"1.{i}.0") + history = db.get_check_history(12345, limit=3) + assert len(history) == 3 + + +def test_check_history_missing_modpack(db): + assert db.get_check_history(99999) == [] + + +# --------------------------------------------------------------------------- +# toggle_notifications +# --------------------------------------------------------------------------- + + +def test_toggle_notifications_disable(db): + db.add_modpack(12345, "Test Pack") + result = db.toggle_notifications(12345, False) + assert result is True + mp = db.get_modpack(12345) + assert mp.notification_enabled is False + + +def test_toggle_notifications_re_enable(db): + db.add_modpack(12345, "Test Pack") + db.toggle_notifications(12345, False) + db.toggle_notifications(12345, True) + assert db.get_modpack(12345).notification_enabled is True + + +def test_toggle_notifications_missing(db): + assert db.toggle_notifications(99999, True) is False + + +# --------------------------------------------------------------------------- +# cascade delete +# --------------------------------------------------------------------------- + + +def test_remove_modpack_also_removes_history(db): + db.add_modpack(12345, "Test Pack") + db.update_version(12345, "1.0.0") + db.update_version(12345, "1.1.0") + db.remove_modpack(12345) + # History should be gone (cascade delete) + assert db.get_check_history(12345) == [] diff --git a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_notifier.py b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_notifier.py index ac01d92..c1bc71c 100644 --- a/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_notifier.py +++ b/docs/tasks/modpack-version-checker/code/modpack-version-checker/tests/test_notifier.py @@ -1 +1,83 @@ -# Placeholder - content in chat history +"""Tests for notifier.py.""" + +import pytest +import responses as responses_lib + +from modpack_checker.notifier import DiscordNotifier, NotificationError + +WEBHOOK_URL = "https://discord.com/api/webhooks/123456/abcdef" + + +@pytest.fixture +def notifier(): + return DiscordNotifier(WEBHOOK_URL, timeout=5) + + +# --------------------------------------------------------------------------- +# send_update +# --------------------------------------------------------------------------- + + +@responses_lib.activate +def test_send_update_success(notifier): + responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204) + # Should not raise + notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0") + + +@responses_lib.activate +def test_send_update_initial_version(notifier): + """old_version=None should be handled gracefully.""" + responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204) + notifier.send_update("Test Pack", 12345, None, "1.0.0") + + +@responses_lib.activate +def test_send_update_bad_response_raises(notifier): + responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=400, body="Bad Request") + with pytest.raises(NotificationError, match="HTTP 400"): + notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0") + + +@responses_lib.activate +def test_send_update_unauthorized_raises(notifier): + responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=401) + with pytest.raises(NotificationError): + notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0") + + +# --------------------------------------------------------------------------- +# test +# --------------------------------------------------------------------------- + + +@responses_lib.activate +def test_test_webhook_success(notifier): + responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204) + notifier.test() # Should not raise + + +@responses_lib.activate +def test_test_webhook_failure_raises(notifier): + responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=404) + with pytest.raises(NotificationError): + notifier.test() + + +# --------------------------------------------------------------------------- +# embed structure +# --------------------------------------------------------------------------- + + +@responses_lib.activate +def test_send_update_embed_contains_modpack_name(notifier): + """Verify the correct embed payload is sent to Discord.""" + responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204) + notifier.send_update("All The Mods 9", 238222, "0.2.0", "0.3.0") + + assert len(responses_lib.calls) == 1 + raw_body = responses_lib.calls[0].request.body + payload = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body + assert "All The Mods 9" in payload + assert "238222" in payload + assert "0.3.0" in payload