From eacbee550650bc6b9a04189178b6ee6057119666 Mon Sep 17 00:00:00 2001 From: Steve Chan <7875793+iefiru@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:21:56 +0800 Subject: [PATCH] Build initial Argus webhook service with dashboard --- .dockerignore | 9 + .env.example | 38 ++ .gitignore | 15 + Dockerfile | 17 + LICENSE.txt | 1 + README.md | 115 +++- SPEC.md | 554 ++++++++++++++++++ pyproject.toml | 88 +++ railway.json | 9 + requirements.txt | 8 + src/argus/__about__.py | 1 + src/argus/__init__.py | 4 + src/argus/auth.py | 46 ++ src/argus/channels.py | 37 ++ src/argus/config.py | 83 +++ src/argus/dashboard/__init__.py | 0 src/argus/dashboard/queries.py | 202 +++++++ src/argus/dashboard/router.py | 208 +++++++ src/argus/dashboard/templates/_base.html | 47 ++ src/argus/dashboard/templates/event.html | 130 ++++ src/argus/dashboard/templates/index.html | 101 ++++ .../dashboard/templates/webhook_logs.html | 166 ++++++ src/argus/database.py | 64 ++ src/argus/discord.py | 34 ++ src/argus/health.py | 78 +++ src/argus/kktix/__init__.py | 0 src/argus/kktix/handler.py | 76 +++ src/argus/kktix/report.py | 162 +++++ src/argus/kktix/router.py | 123 ++++ src/argus/kktix/scraper.py | 113 ++++ src/argus/main.py | 72 +++ src/argus/scheduler.py | 39 ++ src/argus/timeutil.py | 17 + tests/__init__.py | 0 tests/conftest.py | 38 ++ tests/test_auth.py | 92 +++ tests/test_channels.py | 72 +++ tests/test_config.py | 99 ++++ tests/test_dashboard_queries.py | 366 ++++++++++++ tests/test_dashboard_router.py | 449 ++++++++++++++ tests/test_discord.py | 268 +++++++++ tests/test_discord_delta.py | 322 ++++++++++ tests/test_discord_format_manual.py | 117 ++++ tests/test_event_enrichment.py | 80 +++ tests/test_health.py | 140 +++++ tests/test_kktix.py | 78 +++ tests/test_timeutil.py | 32 + tests/test_webhook.py | 471 +++++++++++++++ 48 files changed, 5280 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE.txt create mode 100644 SPEC.md create mode 100644 pyproject.toml create mode 100644 railway.json create mode 100644 requirements.txt create mode 100644 src/argus/__about__.py create mode 100644 src/argus/__init__.py create mode 100644 src/argus/auth.py create mode 100644 src/argus/channels.py create mode 100644 src/argus/config.py create mode 100644 src/argus/dashboard/__init__.py create mode 100644 src/argus/dashboard/queries.py create mode 100644 src/argus/dashboard/router.py create mode 100644 src/argus/dashboard/templates/_base.html create mode 100644 src/argus/dashboard/templates/event.html create mode 100644 src/argus/dashboard/templates/index.html create mode 100644 src/argus/dashboard/templates/webhook_logs.html create mode 100644 src/argus/database.py create mode 100644 src/argus/discord.py create mode 100644 src/argus/health.py create mode 100644 src/argus/kktix/__init__.py create mode 100644 src/argus/kktix/handler.py create mode 100644 src/argus/kktix/report.py create mode 100644 src/argus/kktix/router.py create mode 100644 src/argus/kktix/scraper.py create mode 100644 src/argus/main.py create mode 100644 src/argus/scheduler.py create mode 100644 src/argus/timeutil.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_channels.py create mode 100644 tests/test_config.py create mode 100644 tests/test_dashboard_queries.py create mode 100644 tests/test_dashboard_router.py create mode 100644 tests/test_discord.py create mode 100644 tests/test_discord_delta.py create mode 100644 tests/test_discord_format_manual.py create mode 100644 tests/test_event_enrichment.py create mode 100644 tests/test_health.py create mode 100644 tests/test_kktix.py create mode 100644 tests/test_timeutil.py create mode 100644 tests/test_webhook.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e692198 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.DS_Store +.git +.pytest_cache +.ruff_cache +.venv +__pycache__ +*.py[cod] +argus.db +*.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e68754 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# ── Secrets ─────────────────────────────────────────────────────────────────── + +# Webhook authentication +WEBHOOK_SECRET=your-random-secret-here + +# Discord webhooks per channel (name is case-insensitive in URL; env key must be upper-case) +DISCORD_WEBHOOK_SPRINT=https://discord.com/api/webhooks/... +DISCORD_WEBHOOK_MEETUP=https://discord.com/api/webhooks/... + +# ── Settings ────────────────────────────────────────────────────────────────── + +# Daily report schedule (default: 09:00 Asia/Taipei) +REPORT_HOUR=9 +REPORT_MINUTE=0 +REPORT_TIMEZONE=Asia/Taipei + +# Database path +DB_PATH=argus.db + +# Health check: SQLite connect/query timeout in seconds +HEALTHCHECK_DB_TIMEOUT=1.0 + +# KKTIX organizer subdomain (e.g. "example" for example.kktix.cc) +KKTIX_ORGANIZATION=example + +# ── Dashboard (OAuth) ───────────────────────────────────────────────────────── + +# Google OAuth 2.0 credentials — https://console.cloud.google.com/apis/credentials +# Authorized redirect URI must include: /dashboard/oauth/callback +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= + +# Random ≥32-byte hex for signing session cookies +# Generate with: python -c "import secrets; print(secrets.token_hex(32))" +SESSION_SECRET= + +# Comma-separated email allowlist for dashboard access +ALLOWED_EMAILS=alice@example.com,bob@example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6754e75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.py[cod] +.DS_Store +*.egg-info/ +dist/ +.env +*.db +.hatch/ +.ruff_cache/ +.pytest_cache/ + +# AI assistant configs (personal, not shared) +.claude/ +CLAUDE.md +AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c6276f0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends sqlite3 \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml README.md LICENSE.txt ./ +COPY src ./src + +RUN pip install --no-cache-dir . + +CMD ["argus"] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1 @@ + diff --git a/README.md b/README.md index 4541d78..424a062 100644 --- a/README.md +++ b/README.md @@ -1 +1,114 @@ -# argus +# Argus + +KKTIX webhook receiver with daily Discord reports and a Google-OAuth-protected dashboard for visualizing registration trends. + +## Installation + +```bash +pip install git+https://github.com/yourname/argus.git +``` + +## Environment Variables + +### Secrets + +| Variable | Required | Description | +|----------|----------|-------------| +| `WEBHOOK_SECRET` | Yes | KKTIX auth header value | +| `DISCORD_WEBHOOK_` | Yes (≥1) | Discord webhook URL per channel, e.g. `DISCORD_WEBHOOK_SPRINT` | +| `GOOGLE_OAUTH_CLIENT_ID` | Yes | Google OAuth 2.0 client ID | +| `GOOGLE_OAUTH_CLIENT_SECRET` | Yes | Google OAuth 2.0 client secret | +| `SESSION_SECRET` | Yes | Random ≥32-byte hex string for signing session cookies | + +### Settings + +| Variable | Default | Description | +|----------|---------|-------------| +| `KKTIX_ORGANIZATION` | — | KKTIX organizer subdomain, e.g. `example` for `example.kktix.cc`; required to auto-fetch event start time and capacity | +| `REPORT_HOUR` | `9` | Report hour | +| `REPORT_MINUTE` | `0` | Report minute | +| `REPORT_TIMEZONE` | `Asia/Taipei` | Report timezone | +| `DB_PATH` | `argus.db` | SQLite database path | +| `HEALTHCHECK_DB_TIMEOUT` | `1.0` | `/health` endpoint DB connect timeout in seconds | +| `LOG_LEVEL` | `INFO` | Python application log level | +| `ALLOWED_EMAILS` | — | Comma-separated email allowlist for dashboard access | +| `ARGUS_HTTPS_ONLY` | `0` | Set to `1` to mark session cookies as Secure | + +## Usage + +```bash +argus +``` + +## KKTIX Webhook Setup + +Configure one endpoint per channel. The channel name (case-insensitive) maps to a `DISCORD_WEBHOOK_` env var. + +| Field | Value | +|-------|-------| +| URL | `https://your-domain/webhook/kktix/` | +| Auth header name | `x-kktix-secret` | +| Auth header value | value of `WEBHOOK_SECRET` | + +Example: sending to the `sprint` channel → +URL: `https://your-domain/webhook/kktix/sprint`, env var: `DISCORD_WEBHOOK_SPRINT` + +## Dashboard + +A Google-OAuth-protected web UI for viewing per-event registration time series. + +- **Event list:** `/dashboard` +- **Per-event chart:** `/dashboard/events/{slug}` — line chart of Total + each ticket type, with capacity (horizontal dashed) and event start (vertical dashed) reference lines. + +### One-time Google OAuth setup + +1. Open [Google Cloud Console — Credentials](https://console.cloud.google.com/apis/credentials). +2. Create an **OAuth 2.0 Client ID** (Application type: **Web application**). +3. Under **Authorized redirect URIs**, add: + - `http://localhost:8000/dashboard/oauth/callback` (for local dev) + - `https:///dashboard/oauth/callback` (for production) +4. Copy the **Client ID** and **Client secret** into `.env` as `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`. +5. Generate a session secret: + ```bash + python -c "import secrets; print(secrets.token_hex(32))" + ``` + Put the output into `.env` as `SESSION_SECRET`. +6. List allowed users in `.env` as `ALLOWED_EMAILS=alice@example.com,bob@example.com`. + +### Try it locally + +```bash +set -a && source .env && set +a +hatch run serve +# open http://localhost:8000/dashboard +``` + +You will be redirected to Google to sign in. Only emails in `ALLOWED_EMAILS` are granted access. + +## Production / Deployment + +When deploying (e.g. to Railway): + +- **Railway builds the Dockerfile** using `python:3.12-slim-bookworm`, installs `sqlite3` for SSH database inspection, installs the package with `pip install .`, and starts the `argus` console command. `argus` reads Railway's injected `PORT` environment variable at runtime. +- **Mount a persistent volume** at `/data` (or wherever) and set `DB_PATH=/data/argus.db`. SQLite written to the container's local filesystem will be wiped on every redeploy. +- **`SESSION_SECRET` is required** — the app refuses to boot without it. Generate with `python -c "import secrets; print(secrets.token_hex(32))"`. +- **`PORT` is read from env** automatically (Railway and most container platforms inject it). No code change needed. +- **`ARGUS_HTTPS_ONLY=1`** — set this once the deploy URL is HTTPS-only, to add the `Secure` flag to session cookies. +- **Google OAuth redirect URI** must be added in Cloud Console: `https:///dashboard/oauth/callback`. + +See [SPEC.md → Deployment](SPEC.md#deployment-railway) for the full Railway walkthrough. + +## Development + +Copy `.env.example` to `.env` and fill in the values, then source it before running any command: + +```bash +set -a && source .env && set +a +hatch run serve # start server +hatch run test # run automated tests +hatch run lint # lint +hatch run fmt # format + +# Visual inspection of Discord report (sends a real webhook): +ARGUS_MANUAL_TEST=1 hatch run pytest tests/test_discord_format_manual.py -v -s +``` diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..c907867 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,554 @@ +# Argus — Project Specification + +## Overview + +Argus is a small Python service for receiving external events and pushing notifications. The first feature is a KKTIX webhook receiver that tracks event registrations and sends daily summary reports to Discord. The codebase is structured so that future features (different sources / different purposes) can be added as self-contained packages that share common infrastructure. + +## Architecture + +Argus uses a **vertical slice** layout: each feature owns its full stack (HTTP routing, business logic, persistence access, presentation), and shared infrastructure lives at the package root. + +``` +┌──────────────── feature packages ────────────────┐ +│ kktix/ (future: github_monitor/, ...) │ +│ router · handler · scraper · report │ +└─────────────────────┬────────────────────────────┘ + │ uses +┌─────────────────────▼────────────────────────────┐ +│ shared infrastructure │ +│ discord (transport) channels (resolution) │ +│ database config │ +│ timeutil health │ +└──────────────────────────────────────────────────┘ +``` + +- **`discord.py`** is a thin transport: `post(url, content, embeds)`. It does not understand any feature's data model. +- **`channels.py`** resolves a channel name to a Discord webhook URL via env vars. Source-agnostic. +- **`database.py`** owns SQLite connection and the schema for all features (centralized to keep migrations simple). +- A new feature creates its own package and uses these shared modules; it does **not** modify other features. + +## Tech Stack + +| Layer | Choice | +|-------|--------| +| Language | Python 3.11+ | +| Web framework | FastAPI | +| ASGI server | uvicorn | +| Database | SQLite | +| Container image | `python:3.12-slim-bookworm` with `sqlite3` CLI | +| Scheduler | APScheduler | +| HTTP client | httpx | +| HTML parsing | beautifulsoup4 | +| Package manager | Hatch | +| Deploy target | Railway | + +--- + +## API Reference + +| Method | Path | Auth | Description | Section | +|--------|------|------|-------------|---------| +| `POST` | `/webhook/kktix/{channel}` | `x-kktix-secret` header | Receive KKTIX registration / cancellation webhook | [KKTIX Webhook](#kktix-webhook) | +| `GET` | `/health` | — | Liveness + DB readiness check | [Health Check](#health-check) | +| `GET` | `/dashboard/login` | — | Start Google OAuth flow | [Dashboard](#dashboard) | +| `GET` | `/dashboard/oauth/callback` | — | OAuth redirect target | [Dashboard](#dashboard) | +| `GET` | `/dashboard/logout` | — | Clear session, redirect to login | [Dashboard](#dashboard) | +| `GET` | `/dashboard` | session (HTML) | Event list page | [Dashboard](#dashboard) | +| `GET` | `/dashboard/events/{slug}` | session (HTML) | Per-event chart page | [Dashboard](#dashboard) | +| `GET` | `/dashboard/webhook-logs` | session (HTML) | Webhook log viewer page | [Dashboard](#dashboard) | +| `GET` | `/dashboard/api/events` | session (401) | JSON: event list | [Dashboard](#dashboard) | +| `GET` | `/dashboard/api/events/{slug}/timeseries` | session (401) | JSON: per-event time series | [Dashboard](#dashboard) | +| `DELETE` | `/dashboard/api/events/{slug}` | session (401) | Permanently delete event + its tickets | [Dashboard](#dashboard) | +| `GET` | `/dashboard/api/webhook-logs` | session (401) | JSON: paginated webhook log entries | [Dashboard](#dashboard) | +| `DELETE` | `/dashboard/api/webhook-logs/{id}` | session (401) | Delete a single webhook log entry | [Dashboard](#dashboard) | +| `DELETE` | `/dashboard/api/webhook-logs` | session (401) | Clear all webhook log entries | [Dashboard](#dashboard) | +| `POST` | `/dashboard/api/report/trigger` | session (401) | Run the daily Discord report immediately | [Dashboard](#dashboard) | + +**Auth column legend:** +- `x-kktix-secret header` — request must include header matching `WEBHOOK_SECRET` (constant-time compared) +- `session (HTML)` — protected by signed session cookie; missing/invalid → 302 to `/dashboard/login` +- `session (401)` — same protection but JSON routes return 401 instead of redirecting + +--- + +## Project Structure + +``` +argus/ +├── src/ +│ └── argus/ +│ ├── __about__.py # version +│ ├── __init__.py +│ │ +│ │ # ── feature packages ── +│ ├── kktix/ +│ │ ├── __init__.py +│ │ ├── router.py # POST /webhook/kktix/{channel} +│ │ ├── handler.py # webhook payload → DB writes +│ │ ├── scraper.py # KKTIX page fetch + event enrichment +│ │ └── report.py # daily report query + Discord payload + send +│ ├── dashboard/ +│ │ ├── __init__.py +│ │ ├── router.py # /dashboard/* routes +│ │ ├── queries.py # time series queries +│ │ └── templates/ +│ │ ├── _base.html # shared layout +│ │ ├── index.html # event list +│ │ └── event.html # per-event chart +│ │ +│ │ # ── shared infrastructure ── +│ ├── auth.py # OAuth client + require_login dependency (reusable) +│ ├── discord.py # generic Discord webhook client (post-only) +│ ├── channels.py # channel name validation + URL resolution +│ ├── config.py # Settings + Secrets dataclasses +│ ├── database.py # SQLite init + connection (all features' tables) +│ ├── timeutil.py # UTC datetime helpers +│ │ +│ │ # ── system layer ── +│ ├── health.py # GET /health +│ ├── main.py # FastAPI app + uvicorn entrypoint +│ └── scheduler.py # APScheduler; dispatches each feature's scheduled jobs +│ +├── tests/ +│ ├── conftest.py +│ ├── test_*.py # automated tests +│ └── test_discord_format_manual.py # opt-in test that sends real Discord webhooks +├── .env.example +├── pyproject.toml +├── railway.json +└── README.md +``` + +### Adding a new feature + +1. Create a new package under `src/argus//` (e.g. `github_monitor/`). +2. Inside, organize as needed (`router.py`, `handler.py`, `notifier.py`, …) — the feature owns its own structure. +3. Use shared modules: `discord.post()`, `channels.resolve_webhook_url()`, `get_conn()`, `config.settings`. +4. If the feature exposes HTTP endpoints, register its router in `main.py`. +5. If the feature has scheduled work, register its job in `scheduler.py`. +6. If the feature needs new tables, add them to `database.py`'s `_CREATE_TABLES_SQL`. + +--- + +## Environment Variables + +### Secrets + +| Variable | Required | Description | +|----------|----------|-------------| +| `WEBHOOK_SECRET` | Yes | KKTIX auth header value | +| `DISCORD_WEBHOOK_` | Yes (≥1) | Discord webhook URL per channel, e.g. `DISCORD_WEBHOOK_SPRINT` | +| `GOOGLE_OAUTH_CLIENT_ID` | Yes | Google OAuth 2.0 client ID | +| `GOOGLE_OAUTH_CLIENT_SECRET` | Yes | Google OAuth 2.0 client secret | +| `SESSION_SECRET` | Yes | Random ≥32-byte hex string for signing session cookies | + +### Settings + +| Variable | Default | Description | +|----------|---------|-------------| +| `KKTIX_ORGANIZATION` | — | KKTIX organizer subdomain (e.g. `example` for `example.kktix.cc`) | +| `REPORT_HOUR` | `9` | Daily report hour | +| `REPORT_MINUTE` | `0` | Daily report minute | +| `REPORT_TIMEZONE` | `Asia/Taipei` | Daily report timezone | +| `DB_PATH` | `argus.db` | SQLite file path | +| `HEALTHCHECK_DB_TIMEOUT` | `1.0` | Health check DB timeout in seconds | +| `LOG_LEVEL` | `INFO` | Python application log level | +| `ALLOWED_EMAILS` | — | Comma-separated email allowlist for dashboard access | +| `ARGUS_HTTPS_ONLY` | `0` | Set to `1` to mark session cookies as Secure | + +Config is loaded at startup via `Settings.from_env()` and `Secrets.from_env()` in `config.py`. Secret values are masked in `__repr__`. + +--- + +## Database Schema + +### events + +| Column | Type | Description | +|--------|------|-------------| +| `event_slug` | TEXT, PK | Unique identifier from KKTIX | +| `event_name` | TEXT | Event name | +| `channel` | TEXT | Discord channel (e.g. `SPRINT`) | +| `start_at` | TEXT | Event start time (UTC ISO 8601, no offset) | +| `capacity` | INTEGER | Total attendance cap | +| `created_at` | TEXT | Record creation time (UTC, set by SQLite) | +| `last_reported_at` | TEXT | Timestamp of last successful Discord report (UTC ISO 8601, no offset) | + +`start_at` and `capacity` are populated automatically by scraping the KKTIX event page when the event is first created (see [Event Enrichment](#event-enrichment)). + +### tickets + +| Column | Type | Description | +|--------|------|-------------| +| `ticket_id` | INTEGER, PK | Unique identifier from KKTIX | +| `ticket_name` | TEXT | Ticket type name | +| `event_slug` | TEXT, FK → events | Associated event | +| `order_id` | INTEGER, index | Associated order | +| `order_state` | TEXT | `activated` or `cancelled` | +| `contact_name` | TEXT | Contact person name | +| `contact_email` | TEXT | Contact person email | +| `paid_at` | TEXT | Payment timestamp (UTC ISO 8601, no offset) | +| `cancelled_at` | TEXT | Cancellation timestamp (UTC ISO 8601, no offset) | + +Indexes: `event_slug`, `order_id`, `ticket_name`. + +### webhook_logs + +| Column | Type | Description | +|--------|------|-------------| +| `id` | INTEGER, PK | Auto-increment | +| `method` | TEXT | HTTP method | +| `channel` | TEXT | Normalized channel name (NULL if invalid) | +| `headers` | TEXT (JSON) | All request headers | +| `body` | TEXT (JSON) | Request body | +| `created_at` | TEXT | Record creation time (UTC, set by SQLite) | + +All incoming webhook requests are logged before auth verification, including rejected ones. + +### Datetime Storage + +All application-written timestamps (`paid_at`, `cancelled_at`, `last_reported_at`) are stored as UTC ISO 8601 strings without timezone offset or microseconds: `YYYY-MM-DDTHH:MM:SS`. Conversion is handled by `timeutil.to_utc()` and `timeutil.utcnow_iso()`. + +--- + +## KKTIX Webhook + +### Authentication + +| Field | Value | +|-------|-------| +| Auth header name | `x-kktix-secret` | +| Auth header value | value of `WEBHOOK_SECRET` | + +Secret comparison uses `hmac.compare_digest` to prevent timing attacks. + +### Endpoint + +``` +POST /webhook/kktix/{channel} +``` + +The `channel` path segment maps to a Discord channel. It is case-insensitive and normalized to uppercase. The corresponding `DISCORD_WEBHOOK_` env var must be set. + +**Error responses:** + +| Condition | Status | +|-----------|--------| +| Invalid channel name format | 400 `invalid_channel` | +| Auth failure | 401 `Unauthorized` | +| Channel env var not configured | 503 `channel_not_configured` | + +All requests are logged to `webhook_logs` before any validation. + +### Payload — Registration (`order_activated_paid`) + +```json +{ + "batch_id": "0000000000000001", + "notifications": [ + { + "type": "order_activated_paid", + "event": { + "name": "Example Event", + "slug": "abcd1234" + }, + "order": { + "id": 100000001, + "state": "activated", + "paid_at": "2026-04-18T14:00:54.442+08:00" + }, + "contact": { + "name": "Test User", + "email": "test@example.com", + "mobile": "" + }, + "tickets": [ + { "id": 200000001, "name": "一般票", "price_cents": 0, "price_currency": "TWD" }, + { "id": 200000002, "name": "早鳥票", "price_cents": 0, "price_currency": "TWD" } + ] + } + ] +} +``` + +### Payload — Cancellation (`order_cancelled`) + +```json +{ + "batch_id": "0000000000000002", + "notifications": [ + { + "type": "order_cancelled", + "event": { + "name": "Example Event", + "slug": "abcd1234" + }, + "order": { + "id": 100000001, + "state": "cancelled", + "cancelled_at": "2026-04-18T14:03:50.949+08:00" + } + } + ] +} +``` + +### Processing Logic + +- `order_activated_paid`: + - Upsert event into `events` with channel (`ON CONFLICT DO NOTHING`) + - Insert each ticket into `tickets` with `order_state = 'activated'` (`ON CONFLICT DO NOTHING`) + - If the event is newly inserted (first time seen), schedule [Event Enrichment](#event-enrichment) as a background task +- `order_cancelled`: + - Update all tickets matching `order_id` to `order_state = 'cancelled'`, set `cancelled_at` +- One order may contain multiple tickets +- One webhook payload may contain multiple notifications +- All timestamps converted to UTC before storage + +--- + +## Event Enrichment + +When a new event is first inserted, a background task fetches `start_at` and `capacity` from the KKTIX event page. + +**URL:** `https://{KKTIX_ORGANIZATION}.kktix.cc/events/{slug}` + +**Parsing:** +- `start_at`: extracted from JSON-LD structured data (`"startDate"` field), converted to UTC +- `capacity`: extracted from `{registered} / {capacity}` pattern + +The task is skipped if `start_at` is already populated. All exceptions are swallowed and logged; failures do not affect the webhook response. + +--- + +## Daily Discord Report + +Implemented in `kktix/report.py`. Uses the shared `discord.post()` transport. + +### Schedule + +Configured via `REPORT_HOUR`, `REPORT_MINUTE`, `REPORT_TIMEZONE`. Defaults to 09:00 Asia/Taipei. + +### Channel Selection + +`send_report()` queries the DB for channels with active events: + +```sql +SELECT DISTINCT channel FROM events +WHERE channel IS NOT NULL + AND (start_at IS NULL OR start_at > ) +``` + +Only these channels receive a report. Channels with no active events are skipped entirely. Events with `start_at IS NULL` (not yet enriched) are included. + +### Report Content + +One Discord message per channel, containing one embed per event: + +``` +📊 Argus Daily Registration Summary 2026-04-29 09:00 (Asia/Taipei) + +🎟️ Event Name +一般票 3 (+2) +早鳥票 2 (+0) +───────────── +**Total 5 (+2)** +``` + +- First report for an event (no `last_reported_at`): counts shown without delta +- Subsequent reports: delta shown as `(+N)` or `(-N)` compared to last report +- Embed border color: green (`0x1D9E75`) for net increase, red (`0xE24B4A`) for net decrease, grey (`0x888780`) for no change or first report +- Multiple events in the same channel appear as separate embeds in a single Discord message (Discord limit: 10 embeds per message) +- Discord failure: log status code and response body (up to 500 chars), do not update `last_reported_at`, do not raise exception + +### Delta Calculation + +Delta is computed from the `tickets` table using `events.last_reported_at` as the reference point — no separate snapshot table is maintained. + +```sql +-- Current count (now) +SELECT ticket_name, COUNT(*) AS cnt +FROM tickets +WHERE event_slug = ? AND order_state = 'activated' +GROUP BY ticket_name; + +-- Count at last report time +SELECT ticket_name, COUNT(*) AS cnt +FROM tickets +WHERE event_slug = ? + AND paid_at IS NOT NULL AND paid_at <= + AND (cancelled_at IS NULL OR cancelled_at > ) +GROUP BY ticket_name; +``` + +After each successful send, `events.last_reported_at` is updated to the current UTC time for all events in that channel. + +--- + +## Dashboard + +A web UI that visualizes registration trends per event over time. Implemented as a separate feature package `dashboard/`. Protected by Google OAuth. + +### Routes + +See [API Reference](#api-reference) for the canonical list. All routes under `/dashboard/*` (except `login` and `oauth/callback`) require an authenticated session. HTML routes redirect to `/dashboard/login` on failure; JSON API routes return `401`. + +### Authentication + +Server-side OAuth 2.0 with Google as the identity provider. After successful OAuth, the user's email is checked against `ALLOWED_EMAILS`. If allowed, a signed session cookie is set. + +**Flow:** + +1. Visit `/dashboard` (or any protected route) without session → 302 to `/dashboard/login` +2. `/dashboard/login` → 302 to Google consent screen +3. Google → `/dashboard/oauth/callback?code=...` +4. Backend exchanges code for `id_token`, verifies email is in `ALLOWED_EMAILS` +5. On success: session cookie written, 302 to original destination (or `/dashboard`) +6. On rejection: 403 page + +Session is signed using `SESSION_SECRET` via Starlette's `SessionMiddleware`. + +### Time Series Computation + +No new DB table. Time series is derived from existing `tickets` data using the same logic as report delta calculation. + +For each day `D` in range: + +```sql +SELECT ticket_name, COUNT(*) AS cnt +FROM tickets +WHERE event_slug = ? + AND paid_at IS NOT NULL AND paid_at <= ? -- end of day D in UTC + AND (cancelled_at IS NULL OR cancelled_at > ?) +GROUP BY ticket_name; +``` + +A ticket counts on day `D` if it was paid by end of `D` and either not cancelled or cancelled after `D`. This naturally reflects historical state. + +**Range:** from `date(min(paid_at))` through `min(today, date(events.start_at))` in the configured display timezone. The chart never extends into the future — if `start_at` is upcoming, the chart stops at today and the `start_marker_label` field is `null` so the frontend suppresses the "Event start" annotation. Once `start_at` is reached, the chart extends to that date and the marker appears at the right edge. + +### JSON Response Shape + +`GET /dashboard/api/events`: + +```json +[ + { + "event_slug": "test-event", + "event_name": "Test Event", + "channel": "SPRINT", + "start_at": "2026-04-25T01:00:00", + "capacity": 30 + } +] +``` + +`GET /dashboard/api/events/{slug}/timeseries`: + +```json +{ + "event": { + "event_slug": "test-event", + "event_name": "Test Event", + "channel": "SPRINT", + "start_at": "2026-04-25T01:00:00", + "capacity": 30 + }, + "labels": ["2026-04-15", "2026-04-16", "..."], + "datasets": [ + { "name": "Total", "data": [1, 3, 5, ...] }, + { "name": "一般票", "data": [1, 2, 3, ...] }, + { "name": "早鳥票", "data": [0, 1, 2, ...] } + ], + "start_marker_label": "2026-04-25" +} +``` + +`start_marker_label` is `null` when `start_at` is unset or still in the future. + +### Frontend + +Single Jinja2 template per page; charts rendered client-side with Chart.js (CDN, no build step). + +**Per-event chart features:** +- One line per ticket type, plus a "Total" line +- Horizontal dashed line at `capacity` (when set) +- Vertical dashed line at `start_at` +- Daily granularity on X axis + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `GOOGLE_OAUTH_CLIENT_ID` | Yes | Google OAuth 2.0 client ID | +| `GOOGLE_OAUTH_CLIENT_SECRET` | Yes | Google OAuth 2.0 client secret | +| `SESSION_SECRET` | Yes | Random ≥32-byte hex string for signing session cookies | +| `ALLOWED_EMAILS` | Yes | Comma-separated allowlist, e.g. `alice@example.com,bob@example.com` | + +Google OAuth redirect URI to register in Google Cloud Console: +`https:///dashboard/oauth/callback` + +### Dependencies (additions) + +- `authlib` — OAuth 2.0 client integration with Google +- `jinja2` — HTML templating +- `itsdangerous` — session cookie signing (transitive via Starlette) + +--- + +## Health Check + +``` +GET /health +``` + +Returns DB connectivity status and app version. + +```json +{ + "status": "ok", + "version": "0.1.0", + "checks": { + "database": { "ok": true, "latency_ms": 0.42 } + } +} +``` + +HTTP 200 when healthy, 503 when unhealthy. + +--- + +## Development + +```bash +# Source env vars +set -a && source .env && set +a + +hatch run serve # start server +hatch run test # run automated tests +hatch run lint # ruff check +hatch run fmt # ruff format + +# Visual inspection of Discord report (opt-in, sends a real webhook) +ARGUS_MANUAL_TEST=1 hatch run pytest tests/test_discord_format_manual.py -v -s +``` + +--- + +## Deployment (Railway) + +1. Push repo to GitHub +2. Create new Railway project → Deploy from GitHub repo +3. Add a Volume, mount at `/data`, set `DB_PATH=/data/argus.db` +4. Set all required environment variables in Railway dashboard +5. Railway builds `Dockerfile`, installs the package and SQLite CLI, then starts the installed `argus` console command + +```json +{ + "deploy": { + "startCommand": "argus" + } +} +``` + +Subsequent deploys trigger automatically on `git push`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b7f38ef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,88 @@ +[project] +name = "argus" +dynamic = ["version"] +description = "KKTIX webhook receiver with daily Discord reports" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi", + "uvicorn[standard]", + "apscheduler", + "httpx", + "beautifulsoup4", + "authlib", + "itsdangerous", + "jinja2", +] + +[project.optional-dependencies] +dev = [ + "ruff", + "pytest", + "pytest-asyncio", + "httpx", +] + +[build-system] +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/argus/__about__.py" + +[project.scripts] +argus = "argus.main:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/argus"] + +[tool.hatch.envs.default] +dependencies = [ + "ruff", + "pytest", + "pytest-asyncio", + "httpx", + "beautifulsoup4", + "authlib", + "itsdangerous", + "jinja2", +] + +[tool.hatch.envs.default.scripts] +serve = "argus" +lint = "ruff check src tests" +fmt = "ruff format src tests" +test = "pytest tests" + +[tool.ruff] +output-format = "grouped" +show-fixes = true + +[tool.ruff.lint] +select = [ + "I", # isort + "N", # pep8-naming + "E", # pycodestyle-error + "W", # pycodestyle-warning + "F", # pyflakes + "UP", # pyupgrade +] +ignore = ["E501"] + +[tool.ruff.lint.isort] +from-first = true +lines-after-imports = 2 + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +filterwarnings = [ + # Third-party deprecation: authlib's transitional warning about its own + # jose submodule. Author guarantees compatibility until 2.0.0; nothing for + # us to do until they remove it. + "ignore::authlib.deprecate.AuthlibDeprecationWarning", +] diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..b588677 --- /dev/null +++ b/railway.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE" + }, + "deploy": { + "startCommand": "argus" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e99512c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn[standard] +apscheduler +httpx +beautifulsoup4 +authlib +itsdangerous +jinja2 diff --git a/src/argus/__about__.py b/src/argus/__about__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/argus/__about__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/argus/__init__.py b/src/argus/__init__.py new file mode 100644 index 0000000..286e2aa --- /dev/null +++ b/src/argus/__init__.py @@ -0,0 +1,4 @@ +from argus.__about__ import __version__ + + +__all__ = ["__version__"] diff --git a/src/argus/auth.py b/src/argus/auth.py new file mode 100644 index 0000000..b37a596 --- /dev/null +++ b/src/argus/auth.py @@ -0,0 +1,46 @@ +from authlib.integrations.starlette_client import OAuth +from fastapi import HTTPException, Request, status + +from argus import config + + +# Module-level OAuth instance, lazily configured +_oauth: OAuth | None = None + + +def get_oauth() -> OAuth: + """Lazy init so tests can run without the env vars set.""" + global _oauth + if _oauth is None: + oauth = OAuth() + oauth.register( + name="google", + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_id=config.secrets.require_google_oauth_client_id(), + client_secret=config.secrets.require_google_oauth_client_secret(), + client_kwargs={"scope": "openid email profile"}, + ) + _oauth = oauth + return _oauth + + +def reset_oauth() -> None: + """For tests.""" + global _oauth + _oauth = None + + +def is_email_allowed(email: str) -> bool: + if not email: + return False + return email.lower() in {e.lower() for e in config.settings.allowed_emails} + + +async def require_login(request: Request) -> str: + """FastAPI dependency for API routes. Returns email; raises 401 if not authed.""" + user = request.session.get("user") + if not user or not user.get("email") or not is_email_allowed(user["email"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="login_required" + ) + return user["email"] diff --git a/src/argus/channels.py b/src/argus/channels.py new file mode 100644 index 0000000..7d54319 --- /dev/null +++ b/src/argus/channels.py @@ -0,0 +1,37 @@ +import os +import re + + +CHANNEL_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,31}$") +ENV_PREFIX = "DISCORD_WEBHOOK_" + + +class InvalidChannelError(ValueError): + """Channel name does not match the allowed pattern.""" + + +class ChannelNotConfiguredError(RuntimeError): + def __init__(self, channel: str) -> None: + super().__init__(f"channel not configured: {channel}") + self.channel = channel + + +def normalize(channel: str) -> str: + """Upper-case and validate; raises InvalidChannelError on bad input.""" + up = channel.upper() + if not CHANNEL_RE.match(up): + raise InvalidChannelError(channel) + return up + + +def resolve_webhook_url(channel: str) -> str: + """Return Discord webhook URL for channel, or raise ChannelNotConfiguredError. + + Caller should pre-normalize; this function accepts either form and + normalizes internally for safety. + """ + up = normalize(channel) + url = os.getenv(f"{ENV_PREFIX}{up}", "").strip() + if not url: + raise ChannelNotConfiguredError(up) + return url diff --git a/src/argus/config.py b/src/argus/config.py new file mode 100644 index 0000000..b97c8a4 --- /dev/null +++ b/src/argus/config.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from pathlib import Path +import os + + +@dataclass(frozen=True, slots=True) +class Settings: + report_hour: int + report_minute: int + report_timezone: str + db_path: Path + healthcheck_db_timeout: float + kktix_organization: str + allowed_emails: tuple[str, ...] + + @classmethod + def from_env(cls) -> "Settings": + return cls( + report_hour=int(os.getenv("REPORT_HOUR", "9")), + report_minute=int(os.getenv("REPORT_MINUTE", "0")), + report_timezone=os.getenv("REPORT_TIMEZONE", "Asia/Taipei"), + db_path=Path(os.getenv("DB_PATH", "argus.db")), + healthcheck_db_timeout=float(os.getenv("HEALTHCHECK_DB_TIMEOUT", "1.0")), + kktix_organization=os.getenv("KKTIX_ORGANIZATION", ""), + allowed_emails=tuple( + e.strip() + for e in os.getenv("ALLOWED_EMAILS", "").split(",") + if e.strip() + ), + ) + + +@dataclass(frozen=True, slots=True) +class Secrets: + webhook_secret: str + google_oauth_client_id: str + google_oauth_client_secret: str + session_secret: str + + @classmethod + def from_env(cls) -> "Secrets": + return cls( + webhook_secret=os.getenv("WEBHOOK_SECRET", ""), + google_oauth_client_id=os.getenv("GOOGLE_OAUTH_CLIENT_ID", ""), + google_oauth_client_secret=os.getenv("GOOGLE_OAUTH_CLIENT_SECRET", ""), + session_secret=os.getenv("SESSION_SECRET", ""), + ) + + def __repr__(self) -> str: + return ( + "Secrets(webhook_secret=***, google_oauth_client_id=***, " + "google_oauth_client_secret=***, session_secret=***)" + ) + + def require_webhook_secret(self) -> str: + if not self.webhook_secret: + raise RuntimeError("WEBHOOK_SECRET env var is not set") + return self.webhook_secret + + def require_google_oauth_client_id(self) -> str: + if not self.google_oauth_client_id: + raise RuntimeError("GOOGLE_OAUTH_CLIENT_ID env var is not set") + return self.google_oauth_client_id + + def require_google_oauth_client_secret(self) -> str: + if not self.google_oauth_client_secret: + raise RuntimeError("GOOGLE_OAUTH_CLIENT_SECRET env var is not set") + return self.google_oauth_client_secret + + def require_session_secret(self) -> str: + if not self.session_secret: + raise RuntimeError("SESSION_SECRET env var is not set") + return self.session_secret + + +settings = Settings.from_env() +secrets = Secrets.from_env() + + +def reload() -> None: + global settings, secrets + settings = Settings.from_env() + secrets = Secrets.from_env() diff --git a/src/argus/dashboard/__init__.py b/src/argus/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/argus/dashboard/queries.py b/src/argus/dashboard/queries.py new file mode 100644 index 0000000..e5a3168 --- /dev/null +++ b/src/argus/dashboard/queries.py @@ -0,0 +1,202 @@ +"""Time series queries for the dashboard. + +All timestamps in the DB are stored as UTC ISO 8601 (no offset, no microseconds). +For display, day boundaries are computed in the configured `REPORT_TIMEZONE`. +""" + +from datetime import date, datetime, time, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +from argus import config +from argus.database import get_conn + + +_UTC = ZoneInfo("UTC") + + +def get_event(slug: str) -> dict[str, Any] | None: + """Return event metadata for a single event, or None if not found.""" + with get_conn() as conn: + row = conn.execute( + "SELECT event_slug, event_name, channel, start_at, capacity" + " FROM events WHERE event_slug = ?", + (slug,), + ).fetchone() + return dict(row) if row else None + + +def list_webhook_logs(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]: + """Return recent webhook log entries, newest first.""" + with get_conn() as conn: + rows = conn.execute( + """SELECT id, method, channel, headers, body, created_at + FROM webhook_logs + ORDER BY id DESC + LIMIT ? OFFSET ?""", + (limit, offset), + ).fetchall() + return [dict(r) for r in rows] + + +def count_webhook_logs() -> int: + with get_conn() as conn: + return conn.execute("SELECT COUNT(*) FROM webhook_logs").fetchone()[0] + + +def delete_webhook_log(log_id: int) -> bool: + """Delete a single webhook log entry. Returns True if deleted, False if not found.""" + with get_conn() as conn: + cur = conn.execute("DELETE FROM webhook_logs WHERE id = ?", (log_id,)) + return cur.rowcount > 0 + + +def clear_webhook_logs() -> int: + """Delete all webhook log entries. Returns the number of rows removed.""" + with get_conn() as conn: + cur = conn.execute("DELETE FROM webhook_logs") + return cur.rowcount + + +def delete_event(slug: str) -> bool: + """Delete an event and all of its tickets atomically. + + Returns True if the event existed and was deleted, False if not found. + Children (tickets) are deleted first, then the event row, in a single + transaction (the sqlite3 connection commits at context-manager exit, or + rolls back on exception). + """ + with get_conn() as conn: + existing = conn.execute( + "SELECT 1 FROM events WHERE event_slug = ?", (slug,) + ).fetchone() + if existing is None: + return False + conn.execute("DELETE FROM tickets WHERE event_slug = ?", (slug,)) + conn.execute("DELETE FROM events WHERE event_slug = ?", (slug,)) + return True + + +def list_events() -> list[dict[str, Any]]: + """Return all events that have a channel assigned, newest first.""" + with get_conn() as conn: + rows = conn.execute( + """SELECT event_slug, event_name, channel, start_at, capacity + FROM events + WHERE channel IS NOT NULL + ORDER BY start_at IS NULL, start_at DESC, event_slug""" + ).fetchall() + return [dict(r) for r in rows] + + +def get_timeseries(slug: str) -> dict[str, Any] | None: + """Return event metadata + per-day time series for charting. + + Range: from the local date of the first paid_at, through min(today, start_at). + The chart never extends into the future — if `start_at` is in the future, + the chart stops at today, and the response includes `start_marker_label = None` + so the frontend can suppress the "Event start" annotation. + + Returns None if the event doesn't exist; returns empty datasets if the + event has no tickets yet. + """ + with get_conn() as conn: + event_row = conn.execute( + "SELECT event_slug, event_name, channel, start_at, capacity" + " FROM events WHERE event_slug = ?", + (slug,), + ).fetchone() + if event_row is None: + return None + event = dict(event_row) + + first_paid = conn.execute( + "SELECT MIN(paid_at) FROM tickets" + " WHERE event_slug = ? AND paid_at IS NOT NULL", + (slug,), + ).fetchone()[0] + + if not first_paid: + return { + "event": event, + "labels": [], + "datasets": [], + "start_marker_label": None, + } + + tz = ZoneInfo(config.settings.report_timezone) + today = datetime.now(tz).date() + start_day = _utc_iso_to_local_date(first_paid, tz) + if event["start_at"]: + start_at_local = _utc_iso_to_local_date(event["start_at"], tz) + end_day = min(start_at_local, today) + # The "Event start" marker only makes sense if start_at falls within + # the displayed range. If it's in the future, suppress the marker. + start_marker_label = ( + start_at_local.isoformat() if start_at_local <= today else None + ) + else: + end_day = today + start_marker_label = None + + days = _date_range(start_day, end_day) + boundaries = [_end_of_day_utc(d, tz) for d in days] + + ticket_names = [ + r[0] + for r in conn.execute( + "SELECT DISTINCT ticket_name FROM tickets" + " WHERE event_slug = ? ORDER BY ticket_name", + (slug,), + ) + ] + + # One query per day. For typical ranges (≤90 days) this is fast enough + # on indexed paid_at; if it ever becomes a bottleneck, fold into a + # single CTE-based query. + per_day: list[dict[str, int]] = [] + for boundary in boundaries: + rows = conn.execute( + """SELECT ticket_name, COUNT(*) AS cnt + FROM tickets + WHERE event_slug = ? + AND paid_at IS NOT NULL AND paid_at <= ? + AND (cancelled_at IS NULL OR cancelled_at > ?) + GROUP BY ticket_name""", + (slug, boundary, boundary), + ).fetchall() + per_day.append({r["ticket_name"]: r["cnt"] for r in rows}) + + datasets: list[dict[str, Any]] = [ + {"name": "Total", "data": [sum(d.values()) for d in per_day]}, + ] + for name in ticket_names: + datasets.append({"name": name, "data": [d.get(name, 0) for d in per_day]}) + + return { + "event": event, + "labels": [d.isoformat() for d in days], + "datasets": datasets, + "start_marker_label": start_marker_label, + } + + +def _utc_iso_to_local_date(utc_iso: str, tz: ZoneInfo) -> date: + """Convert a stored UTC ISO 8601 string (no offset) to a local date.""" + return datetime.fromisoformat(utc_iso).replace(tzinfo=_UTC).astimezone(tz).date() + + +def _end_of_day_utc(d: date, tz: ZoneInfo) -> str: + """End-of-day-D-in-tz expressed as UTC ISO 8601 string (no offset, no microseconds).""" + next_midnight = datetime.combine(d + timedelta(days=1), time.min, tzinfo=tz) + end = next_midnight - timedelta(seconds=1) + return end.astimezone(_UTC).replace(tzinfo=None).strftime("%Y-%m-%dT%H:%M:%S") + + +def _date_range(start: date, end: date) -> list[date]: + days: list[date] = [] + d = start + while d <= end: + days.append(d) + d = d + timedelta(days=1) + return days diff --git a/src/argus/dashboard/router.py b/src/argus/dashboard/router.py new file mode 100644 index 0000000..56fc7f0 --- /dev/null +++ b/src/argus/dashboard/router.py @@ -0,0 +1,208 @@ +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo +import asyncio +import logging + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from argus import auth, config +from argus.dashboard import queries +from argus.kktix.report import send_report + + +_TEMPLATES_DIR = Path(__file__).parent / "templates" +_UTC = ZoneInfo("UTC") +templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) + + +def _format_start_at_local(utc_iso: str | None) -> str | None: + """Render a stored UTC ISO 8601 timestamp in the configured display timezone.""" + if not utc_iso: + return None + tz = ZoneInfo(config.settings.report_timezone) + dt = datetime.fromisoformat(utc_iso).replace(tzinfo=_UTC).astimezone(tz) + return dt.strftime(f"%Y-%m-%d %H:%M ({config.settings.report_timezone})") + + +def _session_email_or_redirect(request: Request) -> str | RedirectResponse: + """Returns the authenticated email, or a RedirectResponse to /dashboard/login.""" + user = request.session.get("user") + if not user or not user.get("email") or not auth.is_email_allowed(user["email"]): + return RedirectResponse( + url="/dashboard/login", status_code=status.HTTP_302_FOUND + ) + return user["email"] + + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/dashboard/login") +async def login(request: Request): + redirect_uri = request.url_for("oauth_callback") + return await auth.get_oauth().google.authorize_redirect(request, str(redirect_uri)) + + +@router.get("/dashboard/oauth/callback", name="oauth_callback") +async def oauth_callback(request: Request): + try: + token = await auth.get_oauth().google.authorize_access_token(request) + except Exception as e: + logger.exception("oauth: token exchange failed") + raise HTTPException(status_code=400, detail="oauth_exchange_failed") from e + + userinfo = token.get("userinfo") or {} + email = userinfo.get("email") + if not email: + raise HTTPException(status_code=400, detail="no_email_in_token") + + if not auth.is_email_allowed(email): + logger.warning("oauth: rejected email %s", email) + return HTMLResponse( + "

Access denied

" + "

This account is not authorized to view the dashboard.

", + status_code=403, + ) + + request.session["user"] = {"email": email} + return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) + + +@router.get("/dashboard/logout") +async def logout(request: Request): + request.session.clear() + return RedirectResponse(url="/dashboard/login", status_code=status.HTTP_302_FOUND) + + +@router.get("/dashboard") +async def dashboard_home(request: Request): + result = _session_email_or_redirect(request) + if isinstance(result, RedirectResponse): + return result + return templates.TemplateResponse( + request=request, + name="index.html", + context={"events": queries.list_events(), "user_email": result}, + ) + + +@router.get("/dashboard/events/{slug}") +async def dashboard_event(slug: str, request: Request): + result = _session_email_or_redirect(request) + if isinstance(result, RedirectResponse): + return result + event = queries.get_event(slug) + if event is None: + raise HTTPException(status_code=404, detail="event_not_found") + return templates.TemplateResponse( + request=request, + name="event.html", + context={ + "user_email": result, + "slug": slug, + "event_name": event["event_name"], + "channel": event["channel"], + "start_at": _format_start_at_local(event["start_at"]), + "capacity": event["capacity"], + }, + ) + + +# ── JSON API ──────────────────────────────────────────────────────────────── +# Protected by `Depends(auth.require_login)`. Returns 401 if not authenticated. + + +@router.get("/dashboard/api/events") +async def api_events(_email: str = Depends(auth.require_login)): + return queries.list_events() + + +@router.get("/dashboard/api/events/{slug}/timeseries") +async def api_event_timeseries( + slug: str, _email: str = Depends(auth.require_login) +): + result = queries.get_timeseries(slug) + if result is None: + raise HTTPException(status_code=404, detail="event_not_found") + return result + + +@router.get("/dashboard/webhook-logs") +async def dashboard_webhook_logs(request: Request): + result = _session_email_or_redirect(request) + if isinstance(result, RedirectResponse): + return result + return templates.TemplateResponse( + request=request, + name="webhook_logs.html", + context={ + "user_email": result, + "total": queries.count_webhook_logs(), + }, + ) + + +@router.get("/dashboard/api/webhook-logs") +async def api_webhook_logs( + limit: int = 100, + offset: int = 0, + _email: str = Depends(auth.require_login), +): + # Cap limit defensively to avoid accidentally streaming huge tables. + limit = max(1, min(limit, 500)) + offset = max(0, offset) + return { + "items": queries.list_webhook_logs(limit=limit, offset=offset), + "total": queries.count_webhook_logs(), + "limit": limit, + "offset": offset, + } + + +@router.delete("/dashboard/api/webhook-logs/{log_id}") +async def api_delete_webhook_log( + log_id: int, email: str = Depends(auth.require_login) +): + logger.info("Delete webhook log id=%s by %s", log_id, email) + if not queries.delete_webhook_log(log_id): + raise HTTPException(status_code=404, detail="webhook_log_not_found") + return {"ok": True, "deleted_id": log_id} + + +@router.delete("/dashboard/api/webhook-logs") +async def api_clear_webhook_logs(email: str = Depends(auth.require_login)): + deleted = queries.clear_webhook_logs() + logger.info("Clear webhook_logs by %s: removed %s rows", email, deleted) + return {"ok": True, "deleted_count": deleted} + + +@router.delete("/dashboard/api/events/{slug}") +async def api_delete_event(slug: str, email: str = Depends(auth.require_login)): + """Delete an event and all of its tickets. Idempotent-ish: 404 if already gone.""" + logger.info("Manual event delete by %s: slug=%s", email, slug) + deleted = queries.delete_event(slug) + if not deleted: + raise HTTPException(status_code=404, detail="event_not_found") + return {"ok": True, "deleted_slug": slug} + + +@router.post("/dashboard/api/report/trigger") +async def api_trigger_report(email: str = Depends(auth.require_login)): + """Run the daily Discord report immediately, bypassing the scheduler. + + Per-channel failures are isolated inside `send_report` and logged there; + this endpoint succeeds as long as the dispatch itself doesn't crash. + """ + logger.info("Manual report trigger by %s", email) + try: + # send_report does sync SQLite + httpx work; offload to a thread so it + # doesn't block the asyncio event loop. + await asyncio.to_thread(send_report) + except Exception as e: + logger.exception("Manual report trigger failed") + raise HTTPException(status_code=500, detail="report_failed") from e + return {"ok": True, "message": "Report dispatched"} diff --git a/src/argus/dashboard/templates/_base.html b/src/argus/dashboard/templates/_base.html new file mode 100644 index 0000000..2f1d52e --- /dev/null +++ b/src/argus/dashboard/templates/_base.html @@ -0,0 +1,47 @@ + + + + + + {% block title %}Argus Dashboard{% endblock %} + + + +
+ +
{{ user_email }} · Logout
+
+
+ {% block content %}{% endblock %} +
+ + diff --git a/src/argus/dashboard/templates/event.html b/src/argus/dashboard/templates/event.html new file mode 100644 index 0000000..c920662 --- /dev/null +++ b/src/argus/dashboard/templates/event.html @@ -0,0 +1,130 @@ +{% extends "_base.html" %} +{% block title %}{{ event_name }} — Argus Dashboard{% endblock %} +{% block content %} +← All events +
+

🎟️ {{ event_name }}

+ +
+
+ Channel: {{ channel }} + {% if start_at %}Start: {{ start_at }}{% endif %} + {% if capacity is not none %}Capacity: {{ capacity }}{% endif %} +
+ + +
+ +
+ + + + +{% endblock %} diff --git a/src/argus/dashboard/templates/index.html b/src/argus/dashboard/templates/index.html new file mode 100644 index 0000000..a92e009 --- /dev/null +++ b/src/argus/dashboard/templates/index.html @@ -0,0 +1,101 @@ +{% extends "_base.html" %} +{% block title %}Events — Argus Dashboard{% endblock %} +{% block content %} +
+

Events

+ +
+
+ + + +{% if events %} + + + + + + + + + + + + {% for ev in events %} + + + + + + + + {% endfor %} + +
NameChannelStartCapacity
{{ ev.event_name }}{{ ev.channel }}{{ ev.start_at or "—" }}{{ ev.capacity if ev.capacity is not none else "—" }} + +
+{% else %} +
No events yet.
+{% endif %} + + +{% endblock %} diff --git a/src/argus/dashboard/templates/webhook_logs.html b/src/argus/dashboard/templates/webhook_logs.html new file mode 100644 index 0000000..3c22103 --- /dev/null +++ b/src/argus/dashboard/templates/webhook_logs.html @@ -0,0 +1,166 @@ +{% extends "_base.html" %} +{% block title %}Webhook Logs — Argus Dashboard{% endblock %} +{% block content %} +
+

Webhook Logs ({{ total }} total)

+ +
+
+ + + +
+
Loading…
+
+ + +{% endblock %} diff --git a/src/argus/database.py b/src/argus/database.py new file mode 100644 index 0000000..fa4d524 --- /dev/null +++ b/src/argus/database.py @@ -0,0 +1,64 @@ +from contextlib import contextmanager +import sqlite3 + +from argus import config + + +_CREATE_TABLES_SQL = """ + CREATE TABLE IF NOT EXISTS events ( + event_slug TEXT PRIMARY KEY, + event_name TEXT NOT NULL, + channel TEXT, + start_at TEXT, + capacity INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_reported_at TEXT + ); + + CREATE TABLE IF NOT EXISTS tickets ( + ticket_id INTEGER PRIMARY KEY, + ticket_name TEXT NOT NULL, + event_slug TEXT NOT NULL REFERENCES events(event_slug), + order_id INTEGER NOT NULL, + order_state TEXT NOT NULL, + contact_name TEXT, + contact_email TEXT, + paid_at TEXT, + cancelled_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_tickets_event_slug + ON tickets (event_slug); + CREATE INDEX IF NOT EXISTS idx_tickets_order_id + ON tickets (order_id); + CREATE INDEX IF NOT EXISTS idx_tickets_ticket_name + ON tickets (ticket_name); + + CREATE TABLE IF NOT EXISTS webhook_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + method TEXT NOT NULL, + channel TEXT, + headers TEXT NOT NULL, + body TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); +""" + + +def init_db() -> None: + with get_conn() as conn: + conn.executescript(_CREATE_TABLES_SQL) + + +@contextmanager +def get_conn(): + conn = sqlite3.connect(config.settings.db_path) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/src/argus/discord.py b/src/argus/discord.py new file mode 100644 index 0000000..6e0711c --- /dev/null +++ b/src/argus/discord.py @@ -0,0 +1,34 @@ +import logging + +import httpx + + +logger = logging.getLogger(__name__) + + +def post( + url: str, content: str | None = None, embeds: list[dict] | None = None +) -> bool: + """POST a message payload to a Discord webhook URL. + + Returns True on success (2xx), False otherwise (logs status + body). + Caller decides how to react to failure. + """ + payload: dict = {} + if content is not None: + payload["content"] = content + if embeds is not None: + payload["embeds"] = embeds + + with httpx.Client() as client: + resp = client.post(url, json=payload) + + if not resp.is_success: + logger.error( + "discord: %s returned %s: %s", + url, + resp.status_code, + resp.text[:500], + ) + return False + return True diff --git a/src/argus/health.py b/src/argus/health.py new file mode 100644 index 0000000..2714bc8 --- /dev/null +++ b/src/argus/health.py @@ -0,0 +1,78 @@ +from typing import Literal +import asyncio +import logging +import sqlite3 +import time + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from argus import config + + +try: + from argus.__about__ import __version__ +except ImportError: # pragma: no cover - defensive + __version__ = "unknown" + + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class CheckResult(BaseModel): + ok: bool + latency_ms: float + error: str | None = None + + +class HealthResponse(BaseModel): + status: Literal["ok", "unhealthy"] + version: str + checks: dict[str, CheckResult] + + +def _check_database() -> CheckResult: + start = time.perf_counter() + try: + with sqlite3.connect( + config.settings.db_path, + timeout=config.settings.healthcheck_db_timeout, + ) as conn: + conn.execute("SELECT 1;").fetchone() + latency_ms = (time.perf_counter() - start) * 1000 + return CheckResult(ok=True, latency_ms=round(latency_ms, 2)) + except Exception as e: + latency_ms = (time.perf_counter() - start) * 1000 + return CheckResult( + ok=False, + latency_ms=round(latency_ms, 2), + error=str(e)[:200], + ) + + +@router.get("/health") +async def health() -> JSONResponse: + db_check = await asyncio.to_thread(_check_database) + + checks: dict[str, CheckResult] = {"database": db_check} + all_ok = all(c.ok for c in checks.values()) + status: Literal["ok", "unhealthy"] = "ok" if all_ok else "unhealthy" + + response = HealthResponse( + status=status, + version=__version__, + checks=checks, + ) + + if not all_ok: + for name, result in checks.items(): + if not result.ok: + logger.warning("Health check failed: %s: %s", name, result.error) + + return JSONResponse( + status_code=200 if all_ok else 503, + content=response.model_dump(), + ) diff --git a/src/argus/kktix/__init__.py b/src/argus/kktix/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/argus/kktix/handler.py b/src/argus/kktix/handler.py new file mode 100644 index 0000000..7f0e7b5 --- /dev/null +++ b/src/argus/kktix/handler.py @@ -0,0 +1,76 @@ +import logging + +from argus.database import get_conn +from argus.timeutil import to_utc + + +logger = logging.getLogger(__name__) + + +def _is_kktix_test_notification(event: dict) -> bool: + return event.get("slug") == "event-slug" and event.get("name") == "Event Name" + + +def handle_notification(notification: dict, channel: str) -> list[str]: + type_ = notification.get("type") + event = notification.get("event", {}) + order = notification.get("order", {}) + + event_slug = event.get("slug") + event_name = event.get("name") + order_id = order.get("id") + + if _is_kktix_test_notification(event): + logger.info( + "kktix: ignored test webhook notification type=%s channel=%s", + type_, + channel, + ) + return [] + + new_slugs: list[str] = [] + + if type_ == "order_activated_paid": + contact = notification.get("contact", {}) + tickets = notification.get("tickets", []) + + with get_conn() as conn: + cur = conn.execute( + """INSERT INTO events (event_slug, event_name, channel) + VALUES (?, ?, ?) + ON CONFLICT(event_slug) DO NOTHING""", + (event_slug, event_name, channel), + ) + if cur.rowcount == 1: + new_slugs.append(event_slug) + conn.executemany( + """INSERT INTO tickets + (ticket_id, ticket_name, event_slug, order_id, order_state, + contact_name, contact_email, paid_at) + VALUES (?, ?, ?, ?, 'activated', ?, ?, ?) + ON CONFLICT(ticket_id) DO NOTHING""", + [ + ( + t["id"], + t["name"], + event_slug, + order_id, + contact.get("name"), + contact.get("email"), + to_utc(order.get("paid_at")), + ) + for t in tickets + ], + ) + + elif type_ == "order_cancelled": + with get_conn() as conn: + conn.execute( + """UPDATE tickets + SET order_state = 'cancelled', + cancelled_at = ? + WHERE order_id = ?""", + (to_utc(order.get("cancelled_at")), order_id), + ) + + return new_slugs diff --git a/src/argus/kktix/report.py b/src/argus/kktix/report.py new file mode 100644 index 0000000..fd35a50 --- /dev/null +++ b/src/argus/kktix/report.py @@ -0,0 +1,162 @@ +from datetime import datetime, timedelta, timezone +import logging +import sqlite3 + +from argus import discord +from argus.channels import resolve_webhook_url +from argus.database import get_conn +from argus.timeutil import utcnow_iso + + +logger = logging.getLogger(__name__) + +_COLOR_INCREASE = 0x1D9E75 +_COLOR_DECREASE = 0xE24B4A +_COLOR_NEUTRAL = 0x888780 + + +def build_payload( + rows: list[dict], + event_meta: list[dict], + prev_counts: dict[tuple[str, str], int], +) -> dict: + tw = timezone(timedelta(hours=8)) + now_str = datetime.now(tw).strftime("%Y-%m-%d %H:%M") + + first_report_slugs = { + e["event_slug"] for e in event_meta if e["last_reported_at"] is None + } + + event_map: dict[str, dict] = {} + for row in rows: + slug = row["event_slug"] + if slug not in event_map: + event_map[slug] = {"name": row["event_name"], "tickets": []} + event_map[slug]["tickets"].append(row) + + embeds = [] + for slug, data in event_map.items(): + total_now = 0 + total_prev = 0 + is_first = slug in first_report_slugs + lines = [] + + for t in data["tickets"]: + ticket_name = t["ticket_name"] + count = t["cnt"] + total_now += count + if is_first: + lines.append(f"{ticket_name} {count}") + else: + prev = prev_counts.get((slug, ticket_name), 0) + total_prev += prev + diff = count - prev + delta = f"(+{diff})" if diff >= 0 else f"({diff})" + lines.append(f"{ticket_name} {count} {delta}") + + lines.append("─────────────") + if is_first: + lines.append(f"**Total {total_now}**") + color = _COLOR_NEUTRAL + else: + total_diff = total_now - total_prev + total_delta = f"(+{total_diff})" if total_diff >= 0 else f"({total_diff})" + lines.append(f"**Total {total_now} {total_delta}**") + color = ( + _COLOR_INCREASE + if total_diff > 0 + else _COLOR_DECREASE + if total_diff < 0 + else _COLOR_NEUTRAL + ) + + embeds.append( + { + "title": f"🎟️ {data['name']}", + "description": "\n".join(lines), + "color": color, + } + ) + + if not embeds: + embeds.append( + { + "title": "📋 Argus Daily Registration Summary", + "description": "No active event registrations.", + "color": _COLOR_NEUTRAL, + } + ) + + return { + "content": f"📊 **Argus Daily Registration Summary** {now_str} (Asia/Taipei)", + "embeds": embeds, + } + + +def send_report() -> None: + # Only report on channels that have events whose start_at has not yet passed. + # Events with start_at IS NULL (not yet enriched) are included as well. + with get_conn() as conn: + rows = conn.execute( + """SELECT DISTINCT channel FROM events + WHERE channel IS NOT NULL + AND (start_at IS NULL OR start_at > ?)""", + (utcnow_iso(),), + ).fetchall() + channels = [r["channel"] for r in rows] + if not channels: + logger.info("send_report: no active events found, skipping") + return + for ch in channels: + try: + _send_report_for_channel(conn, ch) + except Exception: + logger.exception("failed to send report for channel %s", ch) + + +def _send_report_for_channel(conn: sqlite3.Connection, channel: str) -> None: + url = resolve_webhook_url(channel) + + # 1. Fetch all events for this channel (with last_reported_at) + event_rows = conn.execute( + "SELECT event_slug, event_name, last_reported_at FROM events WHERE channel = ?", + (channel,), + ).fetchall() + + # 2. now_count per (event_slug, ticket_name) + now_rows = conn.execute( + """SELECT t.event_slug, e.event_name, t.ticket_name, COUNT(*) AS cnt + FROM tickets t + JOIN events e ON e.event_slug = t.event_slug + WHERE e.channel = ? AND t.order_state = 'activated' + GROUP BY t.event_slug, t.ticket_name""", + (channel,), + ).fetchall() + + # 3. prev_count: query once per event that has a last_reported_at + prev_counts: dict[tuple[str, str], int] = {} + for ev in event_rows: + lra = ev["last_reported_at"] + if lra is None: + continue + for r in conn.execute( + """SELECT ticket_name, COUNT(*) AS cnt + FROM tickets + WHERE event_slug = ? + AND paid_at IS NOT NULL AND paid_at <= ? + AND (cancelled_at IS NULL OR cancelled_at > ?) + GROUP BY ticket_name""", + (ev["event_slug"], lra, lra), + ): + prev_counts[(ev["event_slug"], r["ticket_name"])] = r["cnt"] + + event_meta = [dict(r) for r in event_rows] + rows = [dict(r) for r in now_rows] + payload = build_payload(rows, event_meta, prev_counts) + + ok = discord.post(url, **payload) + if ok: + conn.execute( + "UPDATE events SET last_reported_at = ? WHERE channel = ?", + (utcnow_iso(), channel), + ) diff --git a/src/argus/kktix/router.py b/src/argus/kktix/router.py new file mode 100644 index 0000000..05a3ae5 --- /dev/null +++ b/src/argus/kktix/router.py @@ -0,0 +1,123 @@ +import hmac +import json +import logging + +from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request +from fastapi.responses import JSONResponse + +from argus import config +from argus.channels import ( + ChannelNotConfiguredError, + InvalidChannelError, + normalize, + resolve_webhook_url, +) +from argus.database import get_conn +from argus.kktix.handler import handle_notification +from argus.kktix.scraper import enrich_event + + +logger = logging.getLogger(__name__) +router = APIRouter() + +# Headers redacted from webhook_logs to avoid persisting secrets/PII. +# Comparison is case-insensitive. +_SENSITIVE_HEADERS = frozenset( + { + "x-kktix-secret", + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + } +) + + +def _redact_headers(headers: dict[str, str]) -> dict[str, str]: + return { + k: ("***" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items() + } + + +def _redact_body_pii(body: dict) -> dict: + """Redact contact PII (name/email/mobile) before persisting to webhook_logs. + + The contact details are still written to `tickets` (where they are needed + for cancellation lookups). The webhook log only needs the structural shape + for debugging delivery problems, not the personal data. + """ + if not isinstance(body, dict): + return body + redacted = json.loads(json.dumps(body)) # deep copy + for notification in redacted.get("notifications", []) or []: + if isinstance(notification, dict) and "contact" in notification: + contact = notification["contact"] + if isinstance(contact, dict): + for key in ("name", "email", "mobile"): + if key in contact and contact[key]: + contact[key] = "***" + return redacted + + +def _verify_secret(x_kktix_secret: str | None) -> None: + expected = config.secrets.require_webhook_secret() + # Use constant-time comparison to prevent timing attacks that could + # allow an attacker to infer the secret one character at a time. + if x_kktix_secret is None or not hmac.compare_digest(x_kktix_secret, expected): + raise HTTPException(status_code=401, detail="Unauthorized") + + +@router.post("/webhook/kktix/{channel}") +async def receive_kktix_webhook( + channel: str, + request: Request, + background_tasks: BackgroundTasks, + x_kktix_secret: str | None = Header(default=None), +): + body = await request.json() + headers = _redact_headers(dict(request.headers)) + + # Pre-validate channel to decide whether to store normalized name in log. + try: + normalized = normalize(channel) + except InvalidChannelError: + normalized = None # still log request, with channel = NULL + + with get_conn() as conn: + conn.execute( + "INSERT INTO webhook_logs (method, channel, headers, body) VALUES (?, ?, ?, ?)", + ( + request.method, + normalized, + json.dumps(headers), + json.dumps(_redact_body_pii(body)), + ), + ) + + if normalized is None: + return JSONResponse( + status_code=400, + content={"ok": False, "error": "invalid_channel"}, + ) + + _verify_secret(x_kktix_secret) + + try: + resolve_webhook_url(normalized) # ensures channel is configured + except ChannelNotConfiguredError: + logger.error("channel_not_configured: %s", normalized) + return JSONResponse( + status_code=503, + content={ + "ok": False, + "error": "channel_not_configured", + "channel": normalized, + }, + ) + + for notification in body.get("notifications", []): + for slug in handle_notification(notification, channel=normalized): + background_tasks.add_task(enrich_event, slug) + + return {"ok": True} diff --git a/src/argus/kktix/scraper.py b/src/argus/kktix/scraper.py new file mode 100644 index 0000000..c596f02 --- /dev/null +++ b/src/argus/kktix/scraper.py @@ -0,0 +1,113 @@ +from dataclasses import dataclass +import logging +import re + +import httpx + +from argus import config +from argus.database import get_conn +from argus.timeutil import to_utc + + +logger = logging.getLogger(__name__) + +_KKTIX_URL = "https://{org}.kktix.cc/events/{slug}" +_SCRAPE_TIMEOUT = 10.0 +_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" + ), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", +} + +# JSON-LD startDate field +_JSONLD_START_RE = re.compile(r'"startDate"\s*:\s*"([^"]+)"') +# fa-male icon followed by "current / total" +_CAPACITY_RE = re.compile(r'fa-male">\s*\d+\s*/\s*(\d+)') + + +@dataclass(frozen=True) +class EventDetails: + start_at: str | None # UTC ISO "%Y-%m-%dT%H:%M:%S" + capacity: int | None + + +async def fetch_event_details(slug: str) -> EventDetails: + """Fetch KKTIX event page and parse details. + + Requires KKTIX_ORGANIZATION to be set (e.g. "example"), which is the + subdomain of the organizer's KKTIX page (https://{org}.kktix.cc). + """ + org = config.settings.kktix_organization + if not org: + raise RuntimeError("KKTIX_ORGANIZATION env var is not set") + url = _KKTIX_URL.format(org=org, slug=slug) + async with httpx.AsyncClient( + follow_redirects=True, timeout=_SCRAPE_TIMEOUT, headers=_HEADERS + ) as client: + resp = await client.get(url) + resp.raise_for_status() + return parse_event_html(resp.text) + + +def parse_event_html(html: str) -> EventDetails: + return EventDetails( + start_at=_parse_start_at(html), + capacity=_parse_capacity(html), + ) + + +def _parse_start_at(html: str) -> str | None: + # Prefer JSON-LD structured data: "startDate":"2026-04-25T09:00:00.000+08:00" + m = _JSONLD_START_RE.search(html) + if not m: + logger.warning("kktix: startDate not found in JSON-LD") + return None + raw = m.group(1) + try: + return to_utc(raw) + except ValueError: + logger.warning("kktix: invalid startDate: %r", raw) + return None + + +def _parse_capacity(html: str) -> int | None: + # 0 / 30 → 30 + m = _CAPACITY_RE.search(html) + if not m: + logger.warning("kktix: capacity pattern not found") + return None + return int(m.group(1)) + + +async def enrich_event(slug: str) -> None: + """Fetch and store start_at + capacity for a newly created event. + Skips if start_at is already populated. Swallows all exceptions.""" + try: + with get_conn() as conn: + row = conn.execute( + "SELECT start_at FROM events WHERE event_slug = ?", (slug,) + ).fetchone() + if row is None or row["start_at"] is not None: + return + + details = await fetch_event_details(slug) + + with get_conn() as conn: + conn.execute( + """UPDATE events + SET start_at = COALESCE(?, start_at), + capacity = COALESCE(?, capacity) + WHERE event_slug = ? AND start_at IS NULL""", + (details.start_at, details.capacity, slug), + ) + logger.info( + "kktix: enriched event %s start_at=%s capacity=%s", + slug, + details.start_at, + details.capacity, + ) + except Exception: + logger.exception("kktix: failed to enrich event %s", slug) diff --git a/src/argus/main.py b/src/argus/main.py new file mode 100644 index 0000000..e710054 --- /dev/null +++ b/src/argus/main.py @@ -0,0 +1,72 @@ +from contextlib import asynccontextmanager +import logging +import os + +from fastapi import FastAPI +from starlette.middleware.sessions import SessionMiddleware +import uvicorn + +from argus import config +from argus.dashboard.router import router as dashboard_router +from argus.database import init_db +from argus.health import router as health_router +from argus.kktix.router import router as kktix_router +from argus.scheduler import start_scheduler + + +logger = logging.getLogger(__name__) + + +def _configure_logging() -> None: + level_name = os.getenv("LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + logging.basicConfig( + level=level, + format="%(levelname)s:%(name)s:%(message)s", + ) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + init_db() + scheduler = start_scheduler() + yield + if scheduler is not None: + scheduler.shutdown(wait=False) + + +app = FastAPI( + title="Argus", + description="KKTIX webhook receiver with daily Discord reports and analytics dashboard", + lifespan=lifespan, +) + +# Session middleware is global (cookies are I/O only); auth enforcement happens +# per-route via argus.auth.require_login or in-route session checks. +# SESSION_SECRET must be explicitly set — there is no fallback because a known +# fallback key would let anyone forge dashboard sessions. Tests set this via +# fixture; deploys must set it via env. See README → Dashboard. +_session_secret = config.secrets.require_session_secret() + +app.add_middleware( + SessionMiddleware, + secret_key=_session_secret, + same_site="lax", + https_only=os.getenv("ARGUS_HTTPS_ONLY", "0") == "1", +) + +app.include_router(kktix_router) +app.include_router(dashboard_router) +app.include_router(health_router) + + +def main(): + _configure_logging() + # Railway and most container platforms inject $PORT and expect the app to bind it. + uvicorn.run( + "argus.main:app", + host="0.0.0.0", + port=int(os.getenv("PORT", "8000")), + log_config=None, + reload=False, + ) diff --git a/src/argus/scheduler.py b/src/argus/scheduler.py new file mode 100644 index 0000000..c0aa06a --- /dev/null +++ b/src/argus/scheduler.py @@ -0,0 +1,39 @@ +import logging + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from argus import config +from argus.kktix.report import send_report + + +logger = logging.getLogger(__name__) + + +def start_scheduler() -> BackgroundScheduler: + """Start the daily report scheduler. Returns the scheduler so callers + (e.g. the FastAPI lifespan) can shut it down on shutdown.""" + hour = config.settings.report_hour + minute = config.settings.report_minute + timezone = config.settings.report_timezone + + scheduler = BackgroundScheduler() + scheduler.add_job( + _run_report, + CronTrigger(hour=hour, minute=minute, timezone=timezone), + id="daily_report", + replace_existing=True, + ) + scheduler.start() + logger.info( + f"Scheduler started: daily report at {hour:02d}:{minute:02d} ({timezone})" + ) + return scheduler + + +def _run_report() -> None: + try: + send_report() + logger.info("Daily report sent successfully") + except Exception: + logger.exception("Failed to send daily report") diff --git a/src/argus/timeutil.py b/src/argus/timeutil.py new file mode 100644 index 0000000..57a1200 --- /dev/null +++ b/src/argus/timeutil.py @@ -0,0 +1,17 @@ +from datetime import UTC, datetime + + +_ISO_FORMAT = "%Y-%m-%dT%H:%M:%S" + + +def to_utc(value: str | None) -> str | None: + if not value: + return None + dt = datetime.fromisoformat(value) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.astimezone(UTC).replace(tzinfo=None, microsecond=0).strftime(_ISO_FORMAT) + + +def utcnow_iso() -> str: + return datetime.now(UTC).replace(tzinfo=None, microsecond=0).strftime(_ISO_FORMAT) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9bbd378 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +import os + + +# These env vars are read at import time by argus.config / argus.main. +# Set them BEFORE importing argus.* so the singletons are populated. +os.environ.setdefault("SESSION_SECRET", "test-session-secret") +os.environ.setdefault("WEBHOOK_SECRET", "test-secret") + +import pytest # noqa: E402 + +from argus.config import Settings # noqa: E402 +from argus.database import init_db # noqa: E402 +import argus.config # noqa: E402 + + +@pytest.fixture(autouse=True) +def use_tmp_db(tmp_path, monkeypatch): + # Clear any DISCORD_WEBHOOK_* vars that may have leaked from other test modules. + for k in list(os.environ): + if k.startswith("DISCORD_WEBHOOK_"): + monkeypatch.delenv(k, raising=False) + + monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db")) + + new_settings = Settings( + report_hour=argus.config.settings.report_hour, + report_minute=argus.config.settings.report_minute, + report_timezone=argus.config.settings.report_timezone, + db_path=tmp_path / "test.db", + healthcheck_db_timeout=argus.config.settings.healthcheck_db_timeout, + kktix_organization=argus.config.settings.kktix_organization, + allowed_emails=argus.config.settings.allowed_emails, + ) + # Patching argus.config.settings is sufficient; all modules now read via + # `config.settings.` rather than holding a stale local reference. + monkeypatch.setattr(argus.config, "settings", new_settings) + + init_db() diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..6441342 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,92 @@ +"""Tests for argus.auth (OAuth + session guard helpers).""" + +from unittest.mock import MagicMock + +from fastapi import HTTPException +import pytest + +from argus.auth import is_email_allowed, require_login +from argus.config import Settings +import argus.config + + +def _patch_allowed_emails(monkeypatch, emails: tuple[str, ...]) -> None: + new_settings = Settings( + report_hour=argus.config.settings.report_hour, + report_minute=argus.config.settings.report_minute, + report_timezone=argus.config.settings.report_timezone, + db_path=argus.config.settings.db_path, + healthcheck_db_timeout=argus.config.settings.healthcheck_db_timeout, + kktix_organization=argus.config.settings.kktix_organization, + allowed_emails=emails, + ) + monkeypatch.setattr(argus.config, "settings", new_settings) + # auth.py reads settings.allowed_emails at call time, so this is enough. + + +# --------------------------------------------------------------------------- +# is_email_allowed +# --------------------------------------------------------------------------- + + +def test_is_email_allowed_in_list(monkeypatch): + _patch_allowed_emails(monkeypatch, ("alice@example.com", "bob@example.com")) + assert is_email_allowed("alice@example.com") is True + + +def test_is_email_allowed_case_insensitive(monkeypatch): + _patch_allowed_emails(monkeypatch, ("Alice@Example.com",)) + assert is_email_allowed("alice@EXAMPLE.com") is True + + +def test_is_email_allowed_not_in_list(monkeypatch): + _patch_allowed_emails(monkeypatch, ("alice@example.com",)) + assert is_email_allowed("eve@evil.com") is False + + +def test_is_email_allowed_empty_email(monkeypatch): + _patch_allowed_emails(monkeypatch, ("alice@example.com",)) + assert is_email_allowed("") is False + + +def test_is_email_allowed_empty_allowlist(monkeypatch): + _patch_allowed_emails(monkeypatch, ()) + assert is_email_allowed("alice@example.com") is False + + +# --------------------------------------------------------------------------- +# require_login +# --------------------------------------------------------------------------- + + +def _fake_request(session: dict) -> MagicMock: + req = MagicMock() + req.session = session + return req + + +async def test_require_login_no_session(monkeypatch): + _patch_allowed_emails(monkeypatch, ("alice@example.com",)) + with pytest.raises(HTTPException) as exc: + await require_login(_fake_request({})) + assert exc.value.status_code == 401 + + +async def test_require_login_email_not_allowed(monkeypatch): + _patch_allowed_emails(monkeypatch, ("alice@example.com",)) + with pytest.raises(HTTPException) as exc: + await require_login(_fake_request({"user": {"email": "eve@evil.com"}})) + assert exc.value.status_code == 401 + + +async def test_require_login_user_missing_email(monkeypatch): + _patch_allowed_emails(monkeypatch, ("alice@example.com",)) + with pytest.raises(HTTPException) as exc: + await require_login(_fake_request({"user": {}})) + assert exc.value.status_code == 401 + + +async def test_require_login_returns_email(monkeypatch): + _patch_allowed_emails(monkeypatch, ("alice@example.com",)) + email = await require_login(_fake_request({"user": {"email": "alice@example.com"}})) + assert email == "alice@example.com" diff --git a/tests/test_channels.py b/tests/test_channels.py new file mode 100644 index 0000000..a9b4b4b --- /dev/null +++ b/tests/test_channels.py @@ -0,0 +1,72 @@ +import pytest + +from argus.channels import ( + ChannelNotConfiguredError, + InvalidChannelError, + normalize, + resolve_webhook_url, +) + + +# --------------------------------------------------------------------------- +# normalize() +# --------------------------------------------------------------------------- + + +def test_normalize_upper(): + assert normalize("sprint") == "SPRINT" + + +def test_normalize_mixed_case(): + assert normalize("Sprint") == "SPRINT" + + +def test_normalize_already_upper(): + assert normalize("SPRINT") == "SPRINT" + + +def test_normalize_rejects_empty(): + with pytest.raises(InvalidChannelError): + normalize("") + + +def test_normalize_rejects_special_chars(): + for bad in ("a-b", "a.b", "../x", "1abc"): + with pytest.raises(InvalidChannelError): + normalize(bad) + + +def test_normalize_rejects_too_long(): + # 33 upper-case chars (A + 32 more) exceeds {0,31} => total length 33 > 32 + with pytest.raises(InvalidChannelError): + normalize("A" * 33) + + +def test_normalize_accepts_max_length(): + # exactly 32 chars is the maximum (A + 31 more) + assert normalize("A" * 32) == "A" * 32 + + +# --------------------------------------------------------------------------- +# resolve_webhook_url() +# --------------------------------------------------------------------------- + + +def test_resolve_webhook_url_hit(monkeypatch): + monkeypatch.setenv( + "DISCORD_WEBHOOK_SPRINT", "https://discord.com/api/webhooks/test" + ) + assert resolve_webhook_url("SPRINT") == "https://discord.com/api/webhooks/test" + + +def test_resolve_webhook_url_missing(monkeypatch): + monkeypatch.delenv("DISCORD_WEBHOOK_SPRINT", raising=False) + with pytest.raises(ChannelNotConfiguredError) as exc_info: + resolve_webhook_url("SPRINT") + assert exc_info.value.channel == "SPRINT" + + +def test_resolve_webhook_url_empty_string(monkeypatch): + monkeypatch.setenv("DISCORD_WEBHOOK_SPRINT", " ") + with pytest.raises(ChannelNotConfiguredError): + resolve_webhook_url("SPRINT") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..0516f07 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,99 @@ +from pathlib import Path + +import pytest + + +def test_settings_defaults(monkeypatch): + for key in ( + "REPORT_HOUR", + "REPORT_MINUTE", + "REPORT_TIMEZONE", + "DB_PATH", + "HEALTHCHECK_DB_TIMEOUT", + ): + monkeypatch.delenv(key, raising=False) + + from argus.config import Settings + + s = Settings.from_env() + assert s.report_hour == 9 + assert s.report_minute == 0 + assert s.report_timezone == "Asia/Taipei" + assert s.db_path == Path("argus.db") + assert s.healthcheck_db_timeout == 1.0 + + +def test_settings_type_conversion(monkeypatch): + monkeypatch.setenv("REPORT_HOUR", "8") + monkeypatch.setenv("REPORT_MINUTE", "30") + monkeypatch.setenv("REPORT_TIMEZONE", "UTC") + monkeypatch.setenv("DB_PATH", "/tmp/test.db") + monkeypatch.setenv("HEALTHCHECK_DB_TIMEOUT", "2.5") + + from argus.config import Settings + + s = Settings.from_env() + assert isinstance(s.report_hour, int) + assert s.report_hour == 8 + assert isinstance(s.report_minute, int) + assert s.report_minute == 30 + assert isinstance(s.report_timezone, str) + assert s.report_timezone == "UTC" + assert isinstance(s.db_path, Path) + assert s.db_path == Path("/tmp/test.db") + assert isinstance(s.healthcheck_db_timeout, float) + assert s.healthcheck_db_timeout == 2.5 + + +def test_secrets_repr_does_not_leak(): + from argus.config import Secrets + + s = Secrets( + webhook_secret="super-secret", + google_oauth_client_id="client-id-secret", + google_oauth_client_secret="client-secret-secret", + session_secret="session-secret-value", + ) + r = repr(s) + assert "super-secret" not in r + assert "client-id-secret" not in r + assert "client-secret-secret" not in r + assert "session-secret-value" not in r + assert "***" in r + + +def test_secrets_require_webhook_secret_empty(): + from argus.config import Secrets + + s = Secrets( + webhook_secret="", + google_oauth_client_id="", + google_oauth_client_secret="", + session_secret="", + ) + with pytest.raises(RuntimeError, match="WEBHOOK_SECRET"): + s.require_webhook_secret() + + +def test_secrets_require_webhook_secret_returns_value(): + from argus.config import Secrets + + s = Secrets( + webhook_secret="my-secret", + google_oauth_client_id="", + google_oauth_client_secret="", + session_secret="", + ) + assert s.require_webhook_secret() == "my-secret" + + +def test_reload_picks_up_new_env(monkeypatch): + monkeypatch.setenv("REPORT_HOUR", "7") + monkeypatch.setenv("WEBHOOK_SECRET", "new-secret") + + import argus.config + + argus.config.reload() + + assert argus.config.settings.report_hour == 7 + assert argus.config.secrets.webhook_secret == "new-secret" diff --git a/tests/test_dashboard_queries.py b/tests/test_dashboard_queries.py new file mode 100644 index 0000000..097cb74 --- /dev/null +++ b/tests/test_dashboard_queries.py @@ -0,0 +1,366 @@ +"""Tests for argus.dashboard.queries (time series + event listing).""" + +from datetime import datetime, timedelta, timezone + +from argus.dashboard.queries import ( + clear_webhook_logs, + count_webhook_logs, + delete_event, + delete_webhook_log, + get_timeseries, + list_events, + list_webhook_logs, +) +from argus.database import get_conn + + +def _insert_event( + slug: str, + name: str = "Event", + channel: str | None = "SPRINT", + start_at: str | None = None, + capacity: int | None = None, +) -> None: + with get_conn() as conn: + conn.execute( + """INSERT INTO events + (event_slug, event_name, channel, start_at, capacity) + VALUES (?, ?, ?, ?, ?)""", + (slug, name, channel, start_at, capacity), + ) + + +_TID = [0] + + +def _insert_ticket( + event_slug: str, + ticket_name: str, + paid_at: str | None, + cancelled_at: str | None = None, + state: str = "activated", +) -> None: + _TID[0] += 1 + with get_conn() as conn: + conn.execute( + """INSERT INTO tickets + (ticket_id, ticket_name, event_slug, order_id, order_state, paid_at, cancelled_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (_TID[0], ticket_name, event_slug, _TID[0], state, paid_at, cancelled_at), + ) + + +# --------------------------------------------------------------------------- +# list_events +# --------------------------------------------------------------------------- + + +def test_list_events_returns_events_with_channel(): + _insert_event("ev1", channel="SPRINT", start_at="2026-05-01T01:00:00") + _insert_event("ev2", channel="MEETUP", start_at="2026-04-20T01:00:00") + rows = list_events() + slugs = [r["event_slug"] for r in rows] + assert "ev1" in slugs and "ev2" in slugs + + +def test_list_events_excludes_null_channel(): + _insert_event("ev1", channel="SPRINT") + _insert_event("ev_null", channel=None) + rows = list_events() + slugs = [r["event_slug"] for r in rows] + assert "ev1" in slugs + assert "ev_null" not in slugs + + +def test_list_events_orders_by_start_at_desc(): + _insert_event("older", start_at="2026-04-01T01:00:00") + _insert_event("newer", start_at="2026-06-01T01:00:00") + _insert_event("nullstart", start_at=None) + rows = list_events() + slugs = [r["event_slug"] for r in rows] + # Non-null start_at first (newer → older), NULLs last + assert slugs.index("newer") < slugs.index("older") < slugs.index("nullstart") + + +# --------------------------------------------------------------------------- +# get_timeseries — basic shape +# --------------------------------------------------------------------------- + + +def test_timeseries_unknown_event_returns_none(): + assert get_timeseries("does-not-exist") is None + + +def test_timeseries_no_tickets_returns_empty_datasets(): + _insert_event("empty", start_at="2026-05-01T01:00:00") + result = get_timeseries("empty") + assert result is not None + assert result["labels"] == [] + assert result["datasets"] == [] + assert result["event"]["event_slug"] == "empty" + + +def test_timeseries_includes_event_metadata(): + _insert_event("ev1", name="Test Event", start_at="2026-05-01T01:00:00", capacity=30) + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + result = get_timeseries("ev1") + assert result["event"]["event_name"] == "Test Event" + assert result["event"]["start_at"] == "2026-05-01T01:00:00" + assert result["event"]["capacity"] == 30 + + +# --------------------------------------------------------------------------- +# get_timeseries — counts +# --------------------------------------------------------------------------- + + +def test_timeseries_counts_per_day(): + """Tickets paid on different days produce a cumulative time series.""" + # All times stored as UTC; settings.report_timezone defaults to Asia/Taipei (+08:00) + # 2026-04-25T03:00:00 UTC = 2026-04-25 11:00 Taipei → on day 2026-04-25 in TZ + # 2026-04-26T03:00:00 UTC = 2026-04-26 11:00 Taipei → on day 2026-04-26 in TZ + _insert_event("ev1", start_at="2026-04-27T16:00:00") # 2026-04-28 in Taipei + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T04:00:00") + _insert_ticket("ev1", "一般票", paid_at="2026-04-26T03:00:00") + _insert_ticket("ev1", "早鳥票", paid_at="2026-04-25T05:00:00") + + result = get_timeseries("ev1") + labels = result["labels"] + + # Range: 2026-04-25 (first paid in Taipei) → 2026-04-28 (event start in Taipei) + assert labels == ["2026-04-25", "2026-04-26", "2026-04-27", "2026-04-28"] + + datasets = {d["name"]: d["data"] for d in result["datasets"]} + assert datasets["Total"] == [3, 4, 4, 4] + assert datasets["一般票"] == [2, 3, 3, 3] + assert datasets["早鳥票"] == [1, 1, 1, 1] + + +def test_timeseries_excludes_cancelled_after_cancellation(): + """A ticket cancelled mid-range should drop off the count after that date.""" + # start_at 2026-04-28T03:00:00 UTC = 2026-04-28 11:00 Taipei → day 2026-04-28 + _insert_event("ev1", start_at="2026-04-28T03:00:00") + # Paid 2026-04-25 in Taipei, cancelled 2026-04-27 in Taipei (UTC: -8h) + _insert_ticket( + "ev1", + "一般票", + paid_at="2026-04-25T03:00:00", + cancelled_at="2026-04-27T03:00:00", + state="cancelled", + ) + result = get_timeseries("ev1") + datasets = {d["name"]: d["data"] for d in result["datasets"]} + # Day 2026-04-25 (Taipei 23:59:59 = UTC 15:59:59): paid_at=03:00 ≤ 15:59 → counted (1) + # Day 2026-04-26: still active (cancelled_at=2026-04-27T03:00 > 2026-04-26T15:59:59 UTC) → 1 + # Day 2026-04-27: cancelled_at=2026-04-27T03:00 ≤ 2026-04-27T15:59:59 UTC → 0 + # Day 2026-04-28: 0 + assert datasets["Total"] == [1, 1, 0, 0] + + +def test_timeseries_uses_today_when_start_at_missing(monkeypatch): + """Without start_at, range extends to today (in Taipei).""" + _insert_event("ev1", start_at=None) + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + result = get_timeseries("ev1") + # Should have at least one day, ending today (Taipei date) + assert len(result["labels"]) >= 1 + today_taipei = datetime.now(timezone(timedelta(hours=8))).date() + assert result["labels"][-1] == today_taipei.isoformat() + + +def test_timeseries_range_starts_from_first_paid_at(): + _insert_event("ev1", start_at="2026-04-30T16:00:00") # Day 2026-05-01 in Taipei + # First paid: 2026-04-25 in Taipei + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + # Later paid: 2026-04-28 in Taipei + _insert_ticket("ev1", "一般票", paid_at="2026-04-28T03:00:00") + result = get_timeseries("ev1") + assert result["labels"][0] == "2026-04-25" + + +def test_timeseries_caps_at_today_for_future_start_at(): + """If start_at is in the future, labels stop at today and start_marker_label is None.""" + # start_at far in the future + _insert_event("ev1", start_at="2099-01-01T00:00:00") + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + + result = get_timeseries("ev1") + today_taipei = datetime.now(timezone(timedelta(hours=8))).date() + assert result["labels"][-1] == today_taipei.isoformat() + assert result["start_marker_label"] is None + + +def test_timeseries_start_marker_for_past_start_at(): + """If start_at is in the past, start_marker_label points to that local date.""" + _insert_event("ev1", start_at="2026-04-26T03:00:00") # 2026-04-26 in Taipei (past) + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + + result = get_timeseries("ev1") + assert result["start_marker_label"] == "2026-04-26" + # Range stops at start_at (it's already past) + assert result["labels"][-1] == "2026-04-26" + + +def test_timeseries_no_marker_when_start_at_unset(): + _insert_event("ev1", start_at=None) + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + result = get_timeseries("ev1") + assert result["start_marker_label"] is None + + +def test_timeseries_only_paid_tickets_count(): + """Tickets without paid_at are not counted.""" + _insert_event("ev1", start_at="2026-04-26T16:00:00") + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + _insert_ticket("ev1", "一般票", paid_at=None) # not paid yet + result = get_timeseries("ev1") + datasets = {d["name"]: d["data"] for d in result["datasets"]} + assert datasets["一般票"] == [1, 1, 1] + + +def test_timeseries_total_equals_sum_of_ticket_types(): + _insert_event("ev1", start_at="2026-04-26T16:00:00") + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + _insert_ticket("ev1", "早鳥票", paid_at="2026-04-25T03:00:00") + _insert_ticket("ev1", "VIP", paid_at="2026-04-26T03:00:00") + result = get_timeseries("ev1") + datasets = {d["name"]: d["data"] for d in result["datasets"]} + + for i in range(len(result["labels"])): + per_type_sum = sum( + datasets[name][i] + for name in datasets + if name != "Total" + ) + assert datasets["Total"][i] == per_type_sum + + +def test_timeseries_isolates_events(): + """Counts for one event must not include another event's tickets.""" + _insert_event("ev1", start_at="2026-04-26T16:00:00") + _insert_event("ev2", start_at="2026-04-26T16:00:00") + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + _insert_ticket("ev2", "一般票", paid_at="2026-04-25T03:00:00") + _insert_ticket("ev2", "一般票", paid_at="2026-04-25T04:00:00") + r1 = get_timeseries("ev1") + r2 = get_timeseries("ev2") + assert {d["name"]: d["data"][0] for d in r1["datasets"]}["Total"] == 1 + assert {d["name"]: d["data"][0] for d in r2["datasets"]}["Total"] == 2 + + +# --------------------------------------------------------------------------- +# delete_event +# --------------------------------------------------------------------------- + + +def test_delete_event_removes_event_row(): + _insert_event("ev1") + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + + assert delete_event("ev1") is True + + with get_conn() as conn: + ev_count = conn.execute( + "SELECT COUNT(*) FROM events WHERE event_slug = ?", ("ev1",) + ).fetchone()[0] + ticket_count = conn.execute( + "SELECT COUNT(*) FROM tickets WHERE event_slug = ?", ("ev1",) + ).fetchone()[0] + assert ev_count == 0 + assert ticket_count == 0 + + +def test_delete_event_unknown_returns_false(): + assert delete_event("does-not-exist") is False + + +def test_delete_event_isolates_other_events(): + _insert_event("ev1") + _insert_event("ev2") + _insert_ticket("ev1", "一般票", paid_at="2026-04-25T03:00:00") + _insert_ticket("ev2", "一般票", paid_at="2026-04-25T03:00:00") + + delete_event("ev1") + + with get_conn() as conn: + # ev2 untouched + assert ( + conn.execute( + "SELECT COUNT(*) FROM events WHERE event_slug = ?", ("ev2",) + ).fetchone()[0] + == 1 + ) + assert ( + conn.execute( + "SELECT COUNT(*) FROM tickets WHERE event_slug = ?", ("ev2",) + ).fetchone()[0] + == 1 + ) + + +# --------------------------------------------------------------------------- +# webhook_logs queries +# --------------------------------------------------------------------------- + + +def _insert_webhook_log(method: str = "POST", channel: str | None = "SPRINT", + headers: str = '{"x-kktix-secret":"***"}', + body: str = '{"batch_id":"x","notifications":[]}') -> int: + with get_conn() as conn: + cur = conn.execute( + """INSERT INTO webhook_logs (method, channel, headers, body) + VALUES (?, ?, ?, ?)""", + (method, channel, headers, body), + ) + return cur.lastrowid + + +def test_list_webhook_logs_newest_first(): + id1 = _insert_webhook_log(channel="A") + id2 = _insert_webhook_log(channel="B") + id3 = _insert_webhook_log(channel="C") + rows = list_webhook_logs() + ids = [r["id"] for r in rows] + assert ids == [id3, id2, id1] + + +def test_list_webhook_logs_respects_limit_offset(): + for _ in range(5): + _insert_webhook_log() + page1 = list_webhook_logs(limit=2, offset=0) + page2 = list_webhook_logs(limit=2, offset=2) + assert len(page1) == 2 and len(page2) == 2 + assert {r["id"] for r in page1}.isdisjoint({r["id"] for r in page2}) + + +def test_count_webhook_logs(): + assert count_webhook_logs() == 0 + _insert_webhook_log() + _insert_webhook_log() + assert count_webhook_logs() == 2 + + +def test_delete_webhook_log_existing(): + log_id = _insert_webhook_log() + other_id = _insert_webhook_log() + assert delete_webhook_log(log_id) is True + remaining = [r["id"] for r in list_webhook_logs()] + assert log_id not in remaining + assert other_id in remaining + + +def test_delete_webhook_log_unknown(): + assert delete_webhook_log(99999) is False + + +def test_clear_webhook_logs_returns_count_and_empties_table(): + for _ in range(3): + _insert_webhook_log() + deleted = clear_webhook_logs() + assert deleted == 3 + assert count_webhook_logs() == 0 + + +def test_clear_webhook_logs_empty(): + assert clear_webhook_logs() == 0 diff --git a/tests/test_dashboard_router.py b/tests/test_dashboard_router.py new file mode 100644 index 0000000..ad46f69 --- /dev/null +++ b/tests/test_dashboard_router.py @@ -0,0 +1,449 @@ +"""Tests for the Dashboard OAuth router (Phase 1 scaffolding).""" + +from unittest.mock import AsyncMock, MagicMock +import os + +from fastapi.testclient import TestClient +import pytest + + +# Set required env vars BEFORE importing the app, so SessionMiddleware has a key. +os.environ.setdefault("SESSION_SECRET", "test-session-secret-please-rotate") +os.environ.setdefault("GOOGLE_OAUTH_CLIENT_ID", "test-client-id") +os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", "test-client-secret") +os.environ.setdefault("ALLOWED_EMAILS", "alice@example.com") +os.environ.setdefault("WEBHOOK_SECRET", "test-secret") + +from argus.config import Secrets, Settings # noqa: E402 +from argus.main import app # noqa: E402 +import argus.auth as auth_module # noqa: E402 +import argus.config # noqa: E402 + + +@pytest.fixture(autouse=True) +def patch_oauth(monkeypatch): + """Override settings/secrets with allowlist + dummy OAuth credentials per test.""" + new_settings = Settings( + report_hour=argus.config.settings.report_hour, + report_minute=argus.config.settings.report_minute, + report_timezone=argus.config.settings.report_timezone, + db_path=argus.config.settings.db_path, + healthcheck_db_timeout=argus.config.settings.healthcheck_db_timeout, + kktix_organization=argus.config.settings.kktix_organization, + allowed_emails=("alice@example.com",), + ) + new_secrets = Secrets( + webhook_secret="test-secret", + google_oauth_client_id="test-client-id", + google_oauth_client_secret="test-client-secret", + session_secret="test-session-secret-please-rotate", + ) + monkeypatch.setattr(argus.config, "settings", new_settings) + monkeypatch.setattr(argus.config, "secrets", new_secrets) + auth_module.reset_oauth() + + +client = TestClient(app) + + +# --------------------------------------------------------------------------- +# /dashboard — protected, redirects to /dashboard/login when not authed +# --------------------------------------------------------------------------- + + +def test_dashboard_home_unauthed_redirects_to_login(): + resp = client.get("/dashboard", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"] == "/dashboard/login" + + +def test_dashboard_logout_clears_session_and_redirects(): + resp = client.get("/dashboard/logout", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"] == "/dashboard/login" + + +# --------------------------------------------------------------------------- +# /dashboard/login — initiates OAuth flow +# --------------------------------------------------------------------------- + + +def test_dashboard_login_redirects_to_google(): + resp = client.get("/dashboard/login", follow_redirects=False) + assert resp.status_code == 302 + location = resp.headers["location"] + # Authlib's authorize_redirect sends to Google's authorization endpoint + assert location.startswith("https://accounts.google.com/") + + +# --------------------------------------------------------------------------- +# /dashboard/oauth/callback +# --------------------------------------------------------------------------- + + +def _fake_oauth(token: dict | Exception) -> MagicMock: + """Build a fake OAuth instance whose google.authorize_access_token returns or raises.""" + fake = MagicMock() + if isinstance(token, Exception): + fake.google.authorize_access_token = AsyncMock(side_effect=token) + else: + fake.google.authorize_access_token = AsyncMock(return_value=token) + return fake + + +def test_oauth_callback_happy_path(monkeypatch): + fake = _fake_oauth({"userinfo": {"email": "alice@example.com"}}) + monkeypatch.setattr(auth_module, "get_oauth", lambda: fake) + + resp = client.get("/dashboard/oauth/callback", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"] == "/dashboard" + + +def test_oauth_callback_email_not_allowed(monkeypatch): + fake = _fake_oauth({"userinfo": {"email": "eve@evil.com"}}) + monkeypatch.setattr(auth_module, "get_oauth", lambda: fake) + + resp = client.get("/dashboard/oauth/callback", follow_redirects=False) + assert resp.status_code == 403 + assert "Access denied" in resp.text + + +def test_oauth_callback_missing_email(monkeypatch): + fake = _fake_oauth({"userinfo": {}}) + monkeypatch.setattr(auth_module, "get_oauth", lambda: fake) + + resp = client.get("/dashboard/oauth/callback", follow_redirects=False) + assert resp.status_code == 400 + + +def test_oauth_callback_token_exchange_fails(monkeypatch): + fake = _fake_oauth(RuntimeError("simulated failure")) + monkeypatch.setattr(auth_module, "get_oauth", lambda: fake) + + resp = client.get("/dashboard/oauth/callback", follow_redirects=False) + assert resp.status_code == 400 + + +def test_dashboard_home_after_successful_oauth(monkeypatch): + """After OAuth success, the user can access /dashboard.""" + fake = _fake_oauth({"userinfo": {"email": "alice@example.com"}}) + monkeypatch.setattr(auth_module, "get_oauth", lambda: fake) + + # Simulate the redirect chain: callback sets session, then GET /dashboard with same client + callback_resp = client.get("/dashboard/oauth/callback", follow_redirects=False) + assert callback_resp.status_code == 302 + + home_resp = client.get("/dashboard", follow_redirects=False) + assert home_resp.status_code == 200 + assert "alice@example.com" in home_resp.text + + +# --------------------------------------------------------------------------- +# JSON API — auth required, list events, time series +# --------------------------------------------------------------------------- + + +def _login(monkeypatch): + """Helper: simulate a successful OAuth login so subsequent client calls have a session.""" + fake = _fake_oauth({"userinfo": {"email": "alice@example.com"}}) + monkeypatch.setattr(auth_module, "get_oauth", lambda: fake) + resp = client.get("/dashboard/oauth/callback", follow_redirects=False) + assert resp.status_code == 302 + + +def test_api_events_unauthed_returns_401(): + # Use a fresh client to avoid leaked session from previous tests + fresh = TestClient(app) + resp = fresh.get("/dashboard/api/events") + assert resp.status_code == 401 + + +def test_api_events_authed_returns_list(monkeypatch): + from argus.database import get_conn + + with get_conn() as conn: + conn.execute( + "INSERT INTO events (event_slug, event_name, channel, start_at)" + " VALUES (?, ?, ?, ?)", + ("ev1", "Event One", "SPRINT", "2026-05-01T01:00:00"), + ) + + _login(monkeypatch) + resp = client.get("/dashboard/api/events") + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert any(e["event_slug"] == "ev1" for e in body) + + +def test_api_timeseries_unauthed_returns_401(): + fresh = TestClient(app) + resp = fresh.get("/dashboard/api/events/whatever/timeseries") + assert resp.status_code == 401 + + +def test_api_timeseries_unknown_slug_returns_404(monkeypatch): + _login(monkeypatch) + resp = client.get("/dashboard/api/events/not-found/timeseries") + assert resp.status_code == 404 + + +def test_dashboard_home_rendered_html(monkeypatch): + """After login, GET /dashboard renders the events list HTML.""" + from argus.database import get_conn + + with get_conn() as conn: + conn.execute( + "INSERT INTO events (event_slug, event_name, channel, start_at)" + " VALUES (?, ?, ?, ?)", + ("ev1", "Sprint 2026", "SPRINT", "2026-05-01T01:00:00"), + ) + + _login(monkeypatch) + resp = client.get("/dashboard") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + # Basic content checks + assert "Sprint 2026" in resp.text + assert "SPRINT" in resp.text + assert "alice@example.com" in resp.text + assert "/dashboard/events/ev1" in resp.text + + +def test_dashboard_event_page(monkeypatch): + """GET /dashboard/events/{slug} renders the chart page.""" + from argus.database import get_conn + + with get_conn() as conn: + conn.execute( + "INSERT INTO events (event_slug, event_name, channel, start_at, capacity)" + " VALUES (?, ?, ?, ?, ?)", + ("ev1", "Sprint 2026", "SPRINT", "2026-05-01T01:00:00", 30), + ) + + _login(monkeypatch) + resp = client.get("/dashboard/events/ev1") + assert resp.status_code == 200 + assert "Sprint 2026" in resp.text + # Chart.js script tag present + assert "chart.js" in resp.text.lower() + # API endpoint URL referenced in the JS + assert "/dashboard/api/events" in resp.text + + +def test_dashboard_event_page_unknown_slug(monkeypatch): + _login(monkeypatch) + resp = client.get("/dashboard/events/does-not-exist") + assert resp.status_code == 404 + + +def test_dashboard_event_page_unauthed_redirects(): + fresh = TestClient(app) + resp = fresh.get("/dashboard/events/anything", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"] == "/dashboard/login" + + +# --------------------------------------------------------------------------- +# Webhook log API +# --------------------------------------------------------------------------- + + +def _insert_webhook_log(channel: str = "SPRINT") -> int: + from argus.database import get_conn + + with get_conn() as conn: + cur = conn.execute( + """INSERT INTO webhook_logs (method, channel, headers, body) + VALUES (?, ?, ?, ?)""", + ("POST", channel, '{"x-kktix-secret":"***"}', '{"batch_id":"x"}'), + ) + return cur.lastrowid + + +def test_api_webhook_logs_unauthed_returns_401(): + fresh = TestClient(app) + resp = fresh.get("/dashboard/api/webhook-logs") + assert resp.status_code == 401 + + +def test_api_webhook_logs_authed_returns_list(monkeypatch): + _insert_webhook_log() + _insert_webhook_log("MEETUP") + _login(monkeypatch) + resp = client.get("/dashboard/api/webhook-logs") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 2 + assert len(body["items"]) == 2 + assert body["limit"] == 100 + assert body["offset"] == 0 + + +def test_api_webhook_logs_caps_limit(monkeypatch): + _login(monkeypatch) + resp = client.get("/dashboard/api/webhook-logs?limit=99999") + assert resp.status_code == 200 + assert resp.json()["limit"] == 500 # capped + + +def test_api_delete_webhook_log_unauthed_returns_401(): + fresh = TestClient(app) + resp = fresh.delete("/dashboard/api/webhook-logs/1") + assert resp.status_code == 401 + + +def test_api_delete_webhook_log_unknown_returns_404(monkeypatch): + _login(monkeypatch) + resp = client.delete("/dashboard/api/webhook-logs/99999") + assert resp.status_code == 404 + + +def test_api_delete_webhook_log_existing_returns_200(monkeypatch): + log_id = _insert_webhook_log() + _login(monkeypatch) + resp = client.delete(f"/dashboard/api/webhook-logs/{log_id}") + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "deleted_id": log_id} + + +def test_api_clear_webhook_logs_unauthed_returns_401(): + fresh = TestClient(app) + resp = fresh.delete("/dashboard/api/webhook-logs") + assert resp.status_code == 401 + + +def test_api_clear_webhook_logs_returns_count(monkeypatch): + _insert_webhook_log() + _insert_webhook_log() + _insert_webhook_log() + _login(monkeypatch) + resp = client.delete("/dashboard/api/webhook-logs") + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "deleted_count": 3} + + +def test_dashboard_webhook_logs_page_unauthed_redirects(): + fresh = TestClient(app) + resp = fresh.get("/dashboard/webhook-logs", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"] == "/dashboard/login" + + +def test_dashboard_webhook_logs_page_authed_renders(monkeypatch): + _insert_webhook_log() + _login(monkeypatch) + resp = client.get("/dashboard/webhook-logs") + assert resp.status_code == 200 + assert "Webhook Logs" in resp.text + + +# --------------------------------------------------------------------------- +# Event delete API (existing) +# --------------------------------------------------------------------------- + + +def test_api_delete_event_unauthed_returns_401(): + fresh = TestClient(app) + resp = fresh.delete("/dashboard/api/events/anything") + assert resp.status_code == 401 + + +def test_api_delete_event_unknown_slug_returns_404(monkeypatch): + _login(monkeypatch) + resp = client.delete("/dashboard/api/events/does-not-exist") + assert resp.status_code == 404 + + +def test_api_delete_event_removes_data(monkeypatch): + from argus.database import get_conn + + with get_conn() as conn: + conn.execute( + "INSERT INTO events (event_slug, event_name, channel)" + " VALUES (?, ?, ?)", + ("ev1", "Event One", "SPRINT"), + ) + conn.execute( + """INSERT INTO tickets + (ticket_id, ticket_name, event_slug, order_id, order_state) + VALUES (1, '一般票', 'ev1', 101, 'activated')""", + ) + + _login(monkeypatch) + resp = client.delete("/dashboard/api/events/ev1") + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "deleted_slug": "ev1"} + + with get_conn() as conn: + ev_count = conn.execute( + "SELECT COUNT(*) FROM events WHERE event_slug = ?", ("ev1",) + ).fetchone()[0] + ticket_count = conn.execute( + "SELECT COUNT(*) FROM tickets WHERE event_slug = ?", ("ev1",) + ).fetchone()[0] + assert ev_count == 0 + assert ticket_count == 0 + + +def test_api_trigger_report_unauthed_returns_401(): + fresh = TestClient(app) + resp = fresh.post("/dashboard/api/report/trigger") + assert resp.status_code == 401 + + +def test_api_trigger_report_invokes_send_report(monkeypatch): + """Authed POST → send_report is called once and 200 returned.""" + calls = [] + + def fake_send_report(): + calls.append(1) + + monkeypatch.setattr("argus.dashboard.router.send_report", fake_send_report) + + _login(monkeypatch) + resp = client.post("/dashboard/api/report/trigger") + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "message": "Report dispatched"} + assert len(calls) == 1 + + +def test_api_trigger_report_returns_500_on_failure(monkeypatch): + """If send_report raises, endpoint surfaces 500.""" + + def fake_send_report(): + raise RuntimeError("boom") + + monkeypatch.setattr("argus.dashboard.router.send_report", fake_send_report) + + _login(monkeypatch) + resp = client.post("/dashboard/api/report/trigger") + assert resp.status_code == 500 + + +def test_api_timeseries_returns_expected_shape(monkeypatch): + from argus.database import get_conn + + with get_conn() as conn: + conn.execute( + "INSERT INTO events (event_slug, event_name, channel, start_at, capacity)" + " VALUES (?, ?, ?, ?, ?)", + ("ev1", "Event One", "SPRINT", "2026-04-26T16:00:00", 30), + ) + conn.execute( + """INSERT INTO tickets + (ticket_id, ticket_name, event_slug, order_id, order_state, paid_at) + VALUES (1, '一般票', 'ev1', 101, 'activated', '2026-04-25T03:00:00')""", + ) + + _login(monkeypatch) + resp = client.get("/dashboard/api/events/ev1/timeseries") + assert resp.status_code == 200 + body = resp.json() + assert body["event"]["event_slug"] == "ev1" + assert body["event"]["capacity"] == 30 + assert isinstance(body["labels"], list) and len(body["labels"]) > 0 + names = {d["name"] for d in body["datasets"]} + assert "Total" in names + assert "一般票" in names diff --git a/tests/test_discord.py b/tests/test_discord.py new file mode 100644 index 0000000..68ef071 --- /dev/null +++ b/tests/test_discord.py @@ -0,0 +1,268 @@ +import logging + +import httpx + +from argus.database import get_conn +from argus.kktix.report import send_report + + +SPRINT_URL = "https://discord.com/api/webhooks/sprint-test" +MEETUP_URL = "https://discord.com/api/webhooks/meetup-test" + + +def _make_response(status_code: int, url: str) -> httpx.Response: + """Build a properly-bound httpx.Response so raise_for_status() works.""" + req = httpx.Request("POST", url) + return httpx.Response(status_code, request=req) + + +def _insert_event_and_ticket( + channel: str, + event_slug: str, + event_name: str, + ticket_id: int, + ticket_name: str, + order_id: int, +) -> None: + with get_conn() as conn: + conn.execute( + "INSERT INTO events (event_slug, event_name, channel) VALUES (?, ?, ?)" + " ON CONFLICT(event_slug) DO NOTHING", + (event_slug, event_name, channel), + ) + conn.execute( + """INSERT INTO tickets + (ticket_id, ticket_name, event_slug, order_id, order_state) + VALUES (?, ?, ?, ?, 'activated') + ON CONFLICT(ticket_id) DO NOTHING""", + (ticket_id, ticket_name, event_slug, order_id), + ) + + +# --------------------------------------------------------------------------- +# send_report() — no channels configured +# --------------------------------------------------------------------------- + + +def test_send_report_no_active_events_skips(caplog): + # No events in DB → nothing to report + with caplog.at_level(logging.INFO, logger="argus.kktix.report"): + send_report() + + assert any("no active events" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# send_report() — fan-out per channel +# --------------------------------------------------------------------------- + + +def test_send_report_fans_out_per_channel(monkeypatch): + monkeypatch.setenv("DISCORD_WEBHOOK_SPRINT", SPRINT_URL) + monkeypatch.setenv("DISCORD_WEBHOOK_MEETUP", MEETUP_URL) + + _insert_event_and_ticket("SPRINT", "sprint-event", "Sprint Event", 1, "一般票", 101) + _insert_event_and_ticket("MEETUP", "meetup-event", "Meetup Event", 2, "一般票", 102) + + posts: list[tuple[str, dict]] = [] + + import argus.discord as discord_module + + class MockClient: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def post(self, url, json=None): + import json as json_mod + + body = json_mod.loads(json_mod.dumps(json)) + posts.append((url, body)) + return _make_response(204, url) + + monkeypatch.setattr(discord_module.httpx, "Client", MockClient) + + send_report() + + assert len(posts) == 2 + urls = {p[0] for p in posts} + assert SPRINT_URL in urls + assert MEETUP_URL in urls + + # Each payload should only contain the channel's own event + for url, payload in posts: + embeds = payload.get("embeds", []) + if url == SPRINT_URL: + titles = [e.get("title", "") for e in embeds] + assert any("Sprint Event" in t for t in titles) + assert not any("Meetup Event" in t for t in titles) + elif url == MEETUP_URL: + titles = [e.get("title", "") for e in embeds] + assert any("Meetup Event" in t for t in titles) + assert not any("Sprint Event" in t for t in titles) + + +# --------------------------------------------------------------------------- +# Events with channel=NULL are excluded from all reports +# --------------------------------------------------------------------------- + + +def test_send_report_skips_events_without_channel(monkeypatch): + monkeypatch.setenv("DISCORD_WEBHOOK_SPRINT", SPRINT_URL) + + # Insert a NULL-channel event alongside a SPRINT event + with get_conn() as conn: + conn.execute( + "INSERT INTO events (event_slug, event_name, channel) VALUES (?, ?, NULL)", + ("null-event", "Null Channel Event"), + ) + conn.execute( + """INSERT INTO tickets + (ticket_id, ticket_name, event_slug, order_id, order_state) + VALUES (99, '一般票', 'null-event', 999, 'activated')""", + ) + _insert_event_and_ticket("SPRINT", "sprint-event", "Sprint Event", 1, "一般票", 101) + + posts: list[tuple[str, dict]] = [] + + import argus.discord as discord_module + + class MockClient: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def post(self, url, json=None): + posts.append((url, json)) + return _make_response(204, url) + + monkeypatch.setattr(discord_module.httpx, "Client", MockClient) + + send_report() + + # Only SPRINT should receive a report; null-channel event must not appear + assert len(posts) == 1 + url, payload = posts[0] + assert url == SPRINT_URL + titles = [e.get("title", "") for e in payload.get("embeds", [])] + assert not any("Null Channel Event" in t for t in titles) + + +# --------------------------------------------------------------------------- +# One channel failure does not block others +# --------------------------------------------------------------------------- + + +def test_send_report_one_channel_failure_does_not_block_others(monkeypatch, caplog): + monkeypatch.setenv("DISCORD_WEBHOOK_SPRINT", SPRINT_URL) + monkeypatch.setenv("DISCORD_WEBHOOK_MEETUP", MEETUP_URL) + + _insert_event_and_ticket("SPRINT", "sprint-event", "Sprint Event", 1, "一般票", 101) + _insert_event_and_ticket("MEETUP", "meetup-event", "Meetup Event", 2, "一般票", 102) + + posts: list[str] = [] + + import argus.discord as discord_module + + class MockClient: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def post(self, url, json=None): + if url == SPRINT_URL: + return _make_response(502, url) + posts.append(url) + return _make_response(204, url) + + monkeypatch.setattr(discord_module.httpx, "Client", MockClient) + + with caplog.at_level(logging.ERROR, logger="argus.discord"): + send_report() + + # MEETUP should still succeed + assert MEETUP_URL in posts + # Error should be logged for SPRINT + assert any(SPRINT_URL in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# Reports are channel-scoped (last_reported_at per event) +# --------------------------------------------------------------------------- + + +def test_last_reported_at_is_channel_scoped(monkeypatch): + monkeypatch.setenv("DISCORD_WEBHOOK_SPRINT", SPRINT_URL) + monkeypatch.setenv("DISCORD_WEBHOOK_MEETUP", MEETUP_URL) + + # Two channels, distinct events + _insert_event_and_ticket( + "SPRINT", "sprint-event", "Sprint Version", 1, "一般票", 101 + ) + _insert_event_and_ticket( + "MEETUP", "meetup-event", "Meetup Version", 2, "一般票", 102 + ) + + import argus.discord as discord_module + + class MockClient: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def post(self, url, json=None): + return _make_response(204, url) + + monkeypatch.setattr(discord_module.httpx, "Client", MockClient) + + send_report() + + with get_conn() as conn: + sprint_lra = conn.execute( + "SELECT last_reported_at FROM events WHERE event_slug = 'sprint-event'" + ).fetchone()["last_reported_at"] + meetup_lra = conn.execute( + "SELECT last_reported_at FROM events WHERE event_slug = 'meetup-event'" + ).fetchone()["last_reported_at"] + + assert sprint_lra is not None + assert meetup_lra is not None + + +# --------------------------------------------------------------------------- +# Empty channel sends "no data" embed +# --------------------------------------------------------------------------- + + +def test_send_report_no_active_events_sends_no_request(monkeypatch): + monkeypatch.setenv("DISCORD_WEBHOOK_SPRINT", SPRINT_URL) + # No events in DB → send_report should make no HTTP requests + + posts: list = [] + + import argus.discord as discord_module + + class MockClient: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def post(self, url, json=None): + posts.append(url) + return _make_response(204, url) + + monkeypatch.setattr(discord_module.httpx, "Client", MockClient) + + send_report() + + assert len(posts) == 0 diff --git a/tests/test_discord_delta.py b/tests/test_discord_delta.py new file mode 100644 index 0000000..52d27d3 --- /dev/null +++ b/tests/test_discord_delta.py @@ -0,0 +1,322 @@ +"""Tests for last_reported_at-based delta logic in kktix/report.py.""" + +import httpx + +from argus.database import get_conn +from argus.kktix.report import build_payload, send_report + + +SPRINT_URL = "https://discord.com/api/webhooks/sprint-delta-test" +MEETUP_URL = "https://discord.com/api/webhooks/meetup-delta-test" + + +def _make_response(status_code: int, url: str) -> httpx.Response: + req = httpx.Request("POST", url) + return httpx.Response(status_code, request=req) + + +def _mock_client_factory(posts: list, url_map: dict | None = None): + """Return a MockClient class that records posts and returns 204.""" + + class MockClient: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def post(self, url, json=None): + posts.append((url, json)) + return _make_response(204, url) + + return MockClient + + +def _insert_event(channel: str, event_slug: str, event_name: str) -> None: + with get_conn() as conn: + conn.execute( + "INSERT INTO events (event_slug, event_name, channel) VALUES (?, ?, ?)" + " ON CONFLICT(event_slug) DO NOTHING", + (event_slug, event_name, channel), + ) + + +def _insert_ticket( + ticket_id: int, + ticket_name: str, + event_slug: str, + order_id: int, + paid_at: str | None = None, + cancelled_at: str | None = None, + order_state: str = "activated", +) -> None: + with get_conn() as conn: + conn.execute( + """INSERT INTO tickets + (ticket_id, ticket_name, event_slug, order_id, order_state, paid_at, cancelled_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(ticket_id) DO NOTHING""", + ( + ticket_id, + ticket_name, + event_slug, + order_id, + order_state, + paid_at, + cancelled_at, + ), + ) + + +# --------------------------------------------------------------------------- +# build_payload unit tests +# --------------------------------------------------------------------------- + + +def test_build_payload_first_report_no_delta(): + """First report (last_reported_at=None): description shows count only, no delta.""" + rows = [ + { + "event_slug": "ev1", + "event_name": "Event One", + "ticket_name": "一般票", + "cnt": 5, + } + ] + event_meta = [ + {"event_slug": "ev1", "event_name": "Event One", "last_reported_at": None} + ] + + payload = build_payload(rows, event_meta, {}) + + embed = payload["embeds"][0] + desc = embed["description"] + assert "(+" not in desc + assert "一般票 5" in desc + assert "Total 5" in desc + assert embed["color"] == 0x888780 + + +def test_build_payload_second_report_increase(): + """Second report with new tickets: description shows positive delta.""" + rows = [ + { + "event_slug": "ev1", + "event_name": "Event One", + "ticket_name": "一般票", + "cnt": 8, + } + ] + event_meta = [ + { + "event_slug": "ev1", + "event_name": "Event One", + "last_reported_at": "2026-04-18T06:00:00", + } + ] + prev_counts: dict[tuple[str, str], int] = {("ev1", "一般票"): 5} + + payload = build_payload(rows, event_meta, prev_counts) + + embed = payload["embeds"][0] + desc = embed["description"] + assert "一般票 8 (+3)" in desc + assert "Total 8 (+3)" in desc + assert embed["color"] == 0x1D9E75 + + +def test_build_payload_second_report_decrease(): + """Second report with cancellations: description shows negative delta.""" + rows = [ + { + "event_slug": "ev1", + "event_name": "Event One", + "ticket_name": "一般票", + "cnt": 3, + } + ] + event_meta = [ + { + "event_slug": "ev1", + "event_name": "Event One", + "last_reported_at": "2026-04-18T06:00:00", + } + ] + prev_counts: dict[tuple[str, str], int] = {("ev1", "一般票"): 5} + + payload = build_payload(rows, event_meta, prev_counts) + + embed = payload["embeds"][0] + desc = embed["description"] + assert "一般票 3 (-2)" in desc + assert "Total 3 (-2)" in desc + assert embed["color"] == 0xE24B4A + + +# --------------------------------------------------------------------------- +# Integration: send_report sets last_reported_at after first send +# --------------------------------------------------------------------------- + + +def test_first_report_sets_last_reported_at(monkeypatch): + monkeypatch.setenv("DISCORD_WEBHOOK_SPRINT", SPRINT_URL) + + _insert_event("SPRINT", "sprint-event", "Sprint Event") + _insert_ticket(1, "一般票", "sprint-event", 101, paid_at="2026-04-18T06:00:00") + + posts: list = [] + import argus.discord as discord_module + + monkeypatch.setattr(discord_module.httpx, "Client", _mock_client_factory(posts)) + + # Before send: last_reported_at is NULL + with get_conn() as conn: + row = conn.execute( + "SELECT last_reported_at FROM events WHERE event_slug = 'sprint-event'" + ).fetchone() + assert row["last_reported_at"] is None + + send_report() + + assert len(posts) == 1 + embed = posts[0][1]["embeds"][0] + # First report: no delta in description + assert "(+" not in embed["description"] + + # After send: last_reported_at is set + with get_conn() as conn: + row = conn.execute( + "SELECT last_reported_at FROM events WHERE event_slug = 'sprint-event'" + ).fetchone() + assert row["last_reported_at"] is not None + + +def test_second_report_shows_increase_delta(monkeypatch): + """After first report, a new ticket bought → delta ▲ appears on second report.""" + monkeypatch.setenv("DISCORD_WEBHOOK_SPRINT", SPRINT_URL) + + _insert_event("SPRINT", "sprint-event", "Sprint Event") + _insert_ticket(1, "一般票", "sprint-event", 101, paid_at="2026-04-17T06:00:00") + + posts: list = [] + import argus.discord as discord_module + + monkeypatch.setattr(discord_module.httpx, "Client", _mock_client_factory(posts)) + + # First report — sets last_reported_at to roughly now (test runs ~2026-04-22) + send_report() + assert len(posts) == 1 + + # Add a new ticket with a paid_at AFTER the current last_reported_at (future date) + _insert_ticket(2, "一般票", "sprint-event", 102, paid_at="2099-12-31T00:00:00") + + # Second report — should detect the new ticket as delta (paid_at > lra) + send_report() + assert len(posts) == 2 + + embed = posts[1][1]["embeds"][0] + assert "(+1)" in embed["description"] + + +def test_second_report_shows_decrease_delta(monkeypatch): + """After first report, a ticket is cancelled → delta ▼ on second report.""" + monkeypatch.setenv("DISCORD_WEBHOOK_SPRINT", SPRINT_URL) + + _insert_event("SPRINT", "sprint-event", "Sprint Event") + # Two tickets active before first report + _insert_ticket(1, "一般票", "sprint-event", 101, paid_at="2026-04-17T06:00:00") + _insert_ticket(2, "一般票", "sprint-event", 102, paid_at="2026-04-17T07:00:00") + + posts: list = [] + import argus.discord as discord_module + + monkeypatch.setattr(discord_module.httpx, "Client", _mock_client_factory(posts)) + + # First report + send_report() + assert len(posts) == 1 + + # Capture the last_reported_at set after first report + with get_conn() as conn: + lra = conn.execute( + "SELECT last_reported_at FROM events WHERE event_slug = 'sprint-event'" + ).fetchone()["last_reported_at"] + assert lra is not None + + # Cancel ticket 2 after the first report (cancelled_at > lra — use a far-future date) + with get_conn() as conn: + conn.execute( + "UPDATE tickets SET order_state = 'cancelled', cancelled_at = '2099-12-31T10:00:00'" + " WHERE ticket_id = 2" + ) + + # Second report — ticket 2 is cancelled so active count drops from 2 to 1 + send_report() + assert len(posts) == 2 + + embed = posts[1][1]["embeds"][0] + assert "(-1)" in embed["description"] + + +def test_multichannel_report_isolation(monkeypatch): + """Reporting for channel A must not update last_reported_at for channel B.""" + monkeypatch.setenv("DISCORD_WEBHOOK_SPRINT", SPRINT_URL) + monkeypatch.setenv("DISCORD_WEBHOOK_MEETUP", MEETUP_URL) + + _insert_event("SPRINT", "sprint-event", "Sprint Event") + _insert_event("MEETUP", "meetup-event", "Meetup Event") + + import argus.discord as discord_module + + posts: list = [] + monkeypatch.setattr(discord_module.httpx, "Client", _mock_client_factory(posts)) + + send_report() + assert len(posts) == 2 + + with get_conn() as conn: + sprint_lra = conn.execute( + "SELECT last_reported_at FROM events WHERE event_slug = 'sprint-event'" + ).fetchone()["last_reported_at"] + meetup_lra = conn.execute( + "SELECT last_reported_at FROM events WHERE event_slug = 'meetup-event'" + ).fetchone()["last_reported_at"] + + # Both channels must have been updated + assert sprint_lra is not None + assert meetup_lra is not None + + # Now mock only SPRINT to succeed; MEETUP fails — SPRINT should still update + import argus.discord as discord_module2 + + class PartialFailClient: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def post(self, url, json=None): + if url == MEETUP_URL: + raise httpx.ConnectError("simulated failure") + return _make_response(204, url) + + monkeypatch.setattr(discord_module2.httpx, "Client", PartialFailClient) + + # Record meetup lra before second send (sprint_lra already captured above) + meetup_lra_before = meetup_lra + + send_report() + + with get_conn() as conn: + sprint_lra_after = conn.execute( + "SELECT last_reported_at FROM events WHERE event_slug = 'sprint-event'" + ).fetchone()["last_reported_at"] + meetup_lra_after = conn.execute( + "SELECT last_reported_at FROM events WHERE event_slug = 'meetup-event'" + ).fetchone()["last_reported_at"] + + # SPRINT succeeded: its lra should be updated (or at least non-null) + assert sprint_lra_after is not None + # MEETUP failed: its lra should NOT have changed (update happens after raise_for_status) + assert meetup_lra_after == meetup_lra_before diff --git a/tests/test_discord_format_manual.py b/tests/test_discord_format_manual.py new file mode 100644 index 0000000..a5ea4da --- /dev/null +++ b/tests/test_discord_format_manual.py @@ -0,0 +1,117 @@ +"""Manual test: sends REAL Discord webhooks for visual format inspection. + +This test is skipped by default. To run it, source your .env (so +DISCORD_WEBHOOK_SPRINT is set to a real URL) and opt in: + + set -a && source .env && set +a + ARGUS_MANUAL_TEST=1 hatch run pytest tests/test_discord_format_manual.py -v -s + +Sends a single Discord message containing four embeds covering: + - Event A: first report (no prior snapshot, no delta shown) + - Event B: registrations increased since last report (▲) + - Event C: registrations decreased since last report (▼) + - Event D: no change since last report (─) +""" + +import os + +import pytest + +from argus.database import get_conn +from argus.kktix.report import send_report + + +# Capture the real URL at import time, before the autouse fixture clears it. +_REAL_SPRINT_URL = os.environ.get("DISCORD_WEBHOOK_SPRINT") +_MANUAL_FLAG = os.environ.get("ARGUS_MANUAL_TEST") == "1" + +pytestmark = pytest.mark.skipif( + not (_MANUAL_FLAG and _REAL_SPRINT_URL), + reason="manual test: set ARGUS_MANUAL_TEST=1 and source DISCORD_WEBHOOK_SPRINT to run", +) + + +CHANNEL = "SPRINT" +FUTURE_START = "2026-12-01T01:00:00" +LAST_REPORT = "2026-04-20T00:00:00" + + +def _insert_event(slug: str, name: str, last_reported_at: str | None = None) -> None: + with get_conn() as conn: + conn.execute( + "INSERT INTO events (event_slug, event_name, channel, start_at, last_reported_at)" + " VALUES (?, ?, ?, ?, ?)", + (slug, name, CHANNEL, FUTURE_START, last_reported_at), + ) + + +def _insert_ticket( + tid: int, + event_slug: str, + ticket_name: str, + order_id: int, + paid_at: str, + cancelled_at: str | None = None, + state: str = "activated", +) -> None: + with get_conn() as conn: + conn.execute( + """INSERT INTO tickets + (ticket_id, ticket_name, event_slug, order_id, order_state, paid_at, cancelled_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (tid, ticket_name, event_slug, order_id, state, paid_at, cancelled_at), + ) + + +def test_send_real_discord_report(monkeypatch): + # Re-establish the real webhook URL for this test only (autouse fixture cleared it) + monkeypatch.setenv("DISCORD_WEBHOOK_SPRINT", _REAL_SPRINT_URL) + + # Event A — first report (no last_reported_at) + _insert_event("event-a", "Event A") + _insert_ticket(1, "event-a", "一般票", 101, "2026-04-18T03:00:00") + _insert_ticket(2, "event-a", "一般票", 102, "2026-04-19T04:00:00") + _insert_ticket(3, "event-a", "早鳥票", 103, "2026-04-17T02:00:00") + + # Event B — increased since last report (+3 一般票, +1 早鳥票) + _insert_event("event-b", "Event B", last_reported_at=LAST_REPORT) + _insert_ticket(4, "event-b", "一般票", 201, "2026-04-18T03:00:00") + _insert_ticket(5, "event-b", "早鳥票", 202, "2026-04-18T04:00:00") + _insert_ticket(6, "event-b", "一般票", 203, "2026-04-21T03:00:00") + _insert_ticket(7, "event-b", "一般票", 204, "2026-04-21T04:00:00") + _insert_ticket(8, "event-b", "一般票", 205, "2026-04-22T03:00:00") + _insert_ticket(9, "event-b", "早鳥票", 206, "2026-04-22T04:00:00") + + # Event C — decreased since last report (2 cancelled after lra) + _insert_event("event-c", "Event C", last_reported_at=LAST_REPORT) + _insert_ticket(10, "event-c", "一般票", 301, "2026-04-18T03:00:00") + _insert_ticket(11, "event-c", "一般票", 302, "2026-04-18T04:00:00") + _insert_ticket(12, "event-c", "一般票", 303, "2026-04-18T05:00:00") + _insert_ticket( + 13, + "event-c", + "一般票", + 304, + "2026-04-18T06:00:00", + cancelled_at="2026-04-21T03:00:00", + state="cancelled", + ) + _insert_ticket( + 14, + "event-c", + "一般票", + 305, + "2026-04-18T07:00:00", + cancelled_at="2026-04-21T04:00:00", + state="cancelled", + ) + + # Event D — no change since last report + _insert_event("event-d", "Event D", last_reported_at=LAST_REPORT) + _insert_ticket(15, "event-d", "一般票", 401, "2026-04-18T03:00:00") + _insert_ticket(16, "event-d", "一般票", 402, "2026-04-19T03:00:00") + _insert_ticket(17, "event-d", "早鳥票", 403, "2026-04-17T03:00:00") + + print("\nSending real Discord report to SPRINT (4 events)...") + send_report() + print("Done. Check Discord channel.") diff --git a/tests/test_event_enrichment.py b/tests/test_event_enrichment.py new file mode 100644 index 0000000..047fd36 --- /dev/null +++ b/tests/test_event_enrichment.py @@ -0,0 +1,80 @@ +import httpx + +from argus.database import get_conn +from argus.kktix.scraper import EventDetails, enrich_event + + +def _insert_event(slug: str, start_at: str | None = None, capacity: int | None = None): + with get_conn() as conn: + conn.execute( + """INSERT INTO events (event_slug, event_name, channel, start_at, capacity) + VALUES (?, ?, ?, ?, ?)""", + (slug, "Test Event", "SPRINT", start_at, capacity), + ) + + +async def test_skips_when_start_at_already_set(monkeypatch): + _insert_event("my-event", start_at="2026-04-25T01:00:00") + + called = [] + + async def fake_fetch(slug): + called.append(slug) + return EventDetails(start_at="2026-04-25T01:00:00", capacity=30) + + monkeypatch.setattr("argus.kktix.scraper.fetch_event_details", fake_fetch) + + await enrich_event("my-event") + + assert called == [] + + +async def test_populates_start_at_and_capacity_when_null(monkeypatch): + _insert_event("my-event", start_at=None, capacity=None) + + async def fake_fetch(slug): + return EventDetails(start_at="2026-04-25T01:00:00", capacity=30) + + monkeypatch.setattr("argus.kktix.scraper.fetch_event_details", fake_fetch) + + await enrich_event("my-event") + + with get_conn() as conn: + row = conn.execute( + "SELECT start_at, capacity FROM events WHERE event_slug = 'my-event'" + ).fetchone() + assert row["start_at"] == "2026-04-25T01:00:00" + assert row["capacity"] == 30 + + +async def test_swallows_network_error(monkeypatch): + _insert_event("my-event", start_at=None) + + async def fake_fetch(slug): + raise httpx.ConnectError("connection refused") + + monkeypatch.setattr("argus.kktix.scraper.fetch_event_details", fake_fetch) + + # Should not raise + await enrich_event("my-event") + + # start_at remains None + with get_conn() as conn: + row = conn.execute( + "SELECT start_at FROM events WHERE event_slug = 'my-event'" + ).fetchone() + assert row["start_at"] is None + + +async def test_skips_when_event_not_found(monkeypatch): + called = [] + + async def fake_fetch(slug): + called.append(slug) + return EventDetails(start_at="2026-04-25T01:00:00", capacity=30) + + monkeypatch.setattr("argus.kktix.scraper.fetch_event_details", fake_fetch) + + await enrich_event("nonexistent-event") + + assert called == [] diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..9b672c1 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,140 @@ +import asyncio +import os +import sqlite3 +import time + +from fastapi.testclient import TestClient + + +os.environ.setdefault("WEBHOOK_SECRET", "test-secret") +os.environ.setdefault("DISCORD_WEBHOOK_URL", "https://discord.com/api/webhooks/test") +os.environ.setdefault("SESSION_SECRET", "test-session-secret") + +from argus.health import HealthResponse # noqa: E402 +from argus.main import app # noqa: E402 + + +client = TestClient(app) + + +def test_health_ok(): + resp = client.get("/health") + + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("application/json") + + body = resp.json() + assert body["status"] == "ok" + assert body["checks"]["database"]["ok"] is True + assert isinstance(body["checks"]["database"]["latency_ms"], float) + assert body["checks"]["database"]["latency_ms"] >= 0 + assert body["checks"]["database"]["error"] is None + assert body["version"] + assert isinstance(body["version"], str) + + +def test_health_db_unreachable(monkeypatch): + def _boom(*args, **kwargs): + raise sqlite3.OperationalError("unable to open database file") + + monkeypatch.setattr("argus.health.sqlite3.connect", _boom) + + resp = client.get("/health") + + assert resp.status_code == 503 + body = resp.json() + assert body["status"] == "unhealthy" + assert body["checks"]["database"]["ok"] is False + assert body["checks"]["database"]["error"] is not None + assert "unable to open database file" in body["checks"]["database"]["error"] + + +def test_health_response_schema_success(): + resp = client.get("/health") + parsed = HealthResponse.model_validate(resp.json()) + assert parsed.status == "ok" + assert "database" in parsed.checks + + +def test_health_response_schema_failure(monkeypatch): + def _boom(*args, **kwargs): + raise sqlite3.OperationalError("database is locked") + + monkeypatch.setattr("argus.health.sqlite3.connect", _boom) + + resp = client.get("/health") + parsed = HealthResponse.model_validate(resp.json()) + assert parsed.status == "unhealthy" + assert parsed.checks["database"].ok is False + assert parsed.checks["database"].error is not None + + +def test_health_no_auth_required(): + # Send request with no headers at all — must NOT be blocked by WEBHOOK_SECRET + resp = client.get("/health") + assert resp.status_code == 200 + + # Also try with a wrong secret header: should be ignored + resp = client.get("/health", headers={"x-kktix-secret": "wrong"}) + assert resp.status_code == 200 + + +def test_health_does_not_block(monkeypatch): + # Make _check_database simulate a slow sync call; if handler uses + # asyncio.to_thread, concurrent requests should overlap and total + # wall time should be closer to one call than to N sequential calls. + sleep_s = 0.2 + n = 3 + + def slow_check(): + from argus.health import CheckResult + + time.sleep(sleep_s) + return CheckResult(ok=True, latency_ms=sleep_s * 1000) + + monkeypatch.setattr("argus.health._check_database", slow_check) + + async def _run_all(): + import httpx + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as ac: + start = time.perf_counter() + results = await asyncio.gather(*[ac.get("/health") for _ in range(n)]) + elapsed = time.perf_counter() - start + return results, elapsed + + results, elapsed = asyncio.run(_run_all()) + for r in results: + assert r.status_code == 200 + # Should be closer to a single sleep than to n * sleep. Allow generous slack. + assert elapsed < sleep_s * n, ( + f"Handler appears to block event loop: elapsed={elapsed:.3f}s, " + f"sequential would be {sleep_s * n:.3f}s" + ) + + +def test_health_latency_is_numeric_and_rounded(monkeypatch): + resp = client.get("/health") + body = resp.json() + latency = body["checks"]["database"]["latency_ms"] + # round(_, 2) output should have at most 2 decimal places + assert isinstance(latency, (int, float)) + # Confirm rounding to 2 decimal places + assert round(latency, 2) == latency + + +def test_health_error_message_truncated(monkeypatch): + long_msg = "x" * 500 + + def _boom(*args, **kwargs): + raise sqlite3.OperationalError(long_msg) + + monkeypatch.setattr("argus.health.sqlite3.connect", _boom) + resp = client.get("/health") + body = resp.json() + err = body["checks"]["database"]["error"] + assert err is not None + assert len(err) <= 200 diff --git a/tests/test_kktix.py b/tests/test_kktix.py new file mode 100644 index 0000000..d2f263b --- /dev/null +++ b/tests/test_kktix.py @@ -0,0 +1,78 @@ +from argus.kktix.scraper import ( + EventDetails, + _parse_capacity, + _parse_start_at, + parse_event_html, +) + + +# Realistic HTML snippets based on actual KKTIX page structure +SAMPLE_HTML = """ + + + + + 2026/04/25(周六) 09:00(+0800) + ~ 18:00(+0800) + + + 0 / 30 + +""" + +HTML_NO_JSONLD = """ +5 / 50 +""" + +HTML_NO_CAPACITY = """ + +""" + + +def test_parse_event_html_full(): + result = parse_event_html(SAMPLE_HTML) + assert result == EventDetails(start_at="2026-04-25T01:00:00", capacity=30) + + +def test_parse_start_at_from_jsonld(): + result = _parse_start_at(SAMPLE_HTML) + assert result == "2026-04-25T01:00:00" + + +def test_parse_start_at_converts_to_utc(): + # +08:00 → UTC is 8 hours behind, so 09:00+08:00 → 01:00 UTC + html = '{"startDate":"2026-04-25T09:00:00.000+08:00"}' + assert _parse_start_at(html) == "2026-04-25T01:00:00" + + +def test_parse_start_at_missing_returns_none(): + assert _parse_start_at("no json-ld here") is None + + +def test_parse_capacity_slash_format(): + assert _parse_capacity('0 / 30') == 30 + + +def test_parse_capacity_zero_after_slash(): + assert _parse_capacity('0 / 0') == 0 + + +def test_parse_capacity_no_pattern_returns_none(): + assert _parse_capacity("no capacity info") is None + + +def test_parse_event_html_missing_jsonld(): + result = parse_event_html(HTML_NO_JSONLD) + assert result.start_at is None + assert result.capacity == 50 + + +def test_parse_event_html_missing_capacity(): + result = parse_event_html(HTML_NO_CAPACITY) + assert result.start_at == "2026-04-25T01:00:00" + assert result.capacity is None diff --git a/tests/test_timeutil.py b/tests/test_timeutil.py new file mode 100644 index 0000000..c340885 --- /dev/null +++ b/tests/test_timeutil.py @@ -0,0 +1,32 @@ +import re + +from argus.timeutil import to_utc, utcnow_iso + + +def test_to_utc_with_positive_offset(): + assert to_utc("2026-04-18T14:00:54+08:00") == "2026-04-18T06:00:54" + + +def test_to_utc_strips_microseconds_and_offset(): + assert to_utc("2026-04-18T06:00:54.123456+00:00") == "2026-04-18T06:00:54" + + +def test_to_utc_z_suffix(): + assert to_utc("2026-04-18T06:00:54Z") == "2026-04-18T06:00:54" + + +def test_to_utc_none(): + assert to_utc(None) is None + + +def test_to_utc_empty_string(): + assert to_utc("") is None + + +def test_to_utc_naive_treated_as_utc(): + assert to_utc("2026-04-18T06:00:54") == "2026-04-18T06:00:54" + + +def test_utcnow_iso_format(): + result = utcnow_iso() + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", result) diff --git a/tests/test_webhook.py b/tests/test_webhook.py new file mode 100644 index 0000000..416afd5 --- /dev/null +++ b/tests/test_webhook.py @@ -0,0 +1,471 @@ +import os + +from fastapi.testclient import TestClient +import pytest + + +os.environ["WEBHOOK_SECRET"] = "test-secret" +os.environ["DISCORD_WEBHOOK_SPRINT"] = "https://discord.com/api/webhooks/sprint-test" +# argus.main reads SESSION_SECRET at import time and refuses to boot without it. +os.environ.setdefault("SESSION_SECRET", "test-session-secret") + +from argus.config import Secrets # noqa: E402 +from argus.database import get_conn # noqa: E402 +from argus.main import app # noqa: E402 +import argus.config # noqa: E402 + + +@pytest.fixture(autouse=True) +def setup_secrets(monkeypatch): + """conftest.use_tmp_db handles the DB + settings; this fixture just sets secrets.""" + monkeypatch.setenv( + "DISCORD_WEBHOOK_SPRINT", "https://discord.com/api/webhooks/sprint-test" + ) + new_secrets = Secrets( + webhook_secret="test-secret", + google_oauth_client_id="", + google_oauth_client_secret="", + session_secret="test-session-secret", + ) + monkeypatch.setattr(argus.config, "secrets", new_secrets) + + +client = TestClient(app) + +SPRINT_URL = "/webhook/kktix/sprint" + +ACTIVATED_PAYLOAD = { + "batch_id": "test001", + "notifications": [ + { + "type": "order_activated_paid", + "event": {"name": "Test Event", "slug": "test-event"}, + "order": { + "id": 1001, + "state": "activated", + "paid_at": "2026-04-18T10:00:00+08:00", + }, + "contact": {"name": "Test User", "email": "test@example.com", "mobile": ""}, + "tickets": [ + { + "id": 5001, + "name": "一般票", + "price_cents": 0, + "price_currency": "TWD", + }, + { + "id": 5002, + "name": "早鳥票", + "price_cents": 0, + "price_currency": "TWD", + }, + ], + } + ], +} + +CANCELLED_PAYLOAD = { + "batch_id": "test002", + "notifications": [ + { + "type": "order_cancelled", + "event": {"name": "Test Event", "slug": "test-event"}, + "order": { + "id": 1001, + "state": "cancelled", + "cancelled_at": "2026-04-18T11:00:00+08:00", + }, + } + ], +} + + +def test_webhook_log_redacts_sensitive_headers(): + """webhook_logs.headers must mask the secret and other sensitive values.""" + import json as _json + + resp = client.post( + SPRINT_URL, + json=ACTIVATED_PAYLOAD, + headers={ + "x-kktix-secret": "test-secret", + "Authorization": "Bearer leak-me", + "Cookie": "session=abc", + }, + ) + assert resp.status_code == 200 + + with get_conn() as conn: + row = conn.execute( + "SELECT headers FROM webhook_logs ORDER BY id DESC LIMIT 1" + ).fetchone() + headers = _json.loads(row["headers"]) + + # Compare keys case-insensitively (Starlette lowercases headers) + lower = {k.lower(): v for k, v in headers.items()} + assert lower["x-kktix-secret"] == "***" + assert lower["authorization"] == "***" + assert lower["cookie"] == "***" + + # The raw secret values must NOT appear anywhere in the stored headers + raw = _json.dumps(headers) + assert "test-secret" not in raw + assert "leak-me" not in raw + assert "session=abc" not in raw + + +def test_webhook_accepts_lowercase_channel(): + resp = client.post( + SPRINT_URL, + json=ACTIVATED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + + with get_conn() as conn: + row = conn.execute( + "SELECT channel FROM events WHERE event_slug = 'test-event'" + ).fetchone() + assert row is not None + assert row["channel"] == "SPRINT" + + +def test_webhook_ignores_kktix_test_notification(caplog): + payload = { + "notifications": [ + { + "type": "order_activated_paid", + "event": {"name": "Event Name", "slug": "event-slug"}, + "order": { + "id": 123, + "status": "activated", + "created_at": "2024-12-25 12:00:00", + "payment_status": "paid", + "paid_at": "2024-12-25 12:30:00", + "total_amount": 4000, + "currency": "TWD", + }, + "contact": {"name": "***", "email": "***"}, + "tickets": [ + { + "price": 1000, + "name": "normal", + "id": 12, + "attendee": { + "name": "User_1", + "email": "user_1@example.com", + }, + } + ], + }, + { + "type": "order_cancelled", + "event": {"name": "Event Name", "slug": "event-slug"}, + "order": { + "id": 123, + "status": "cancelled", + "cancelled_at": "2024-12-25 13:00:00", + }, + }, + ] + } + + with caplog.at_level("INFO", logger="argus.kktix.handler"): + resp = client.post( + SPRINT_URL, + json=payload, + headers={"x-kktix-secret": "test-secret"}, + ) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + assert "ignored test webhook notification" in caplog.text + + with get_conn() as conn: + log_row = conn.execute( + "SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1" + ).fetchone() + event_row = conn.execute( + "SELECT event_slug FROM events WHERE event_slug = 'event-slug'" + ).fetchone() + ticket_row = conn.execute( + "SELECT ticket_id FROM tickets WHERE event_slug = 'event-slug'" + ).fetchone() + + assert log_row is not None + assert event_row is None + assert ticket_row is None + + +def test_webhook_accepts_mixed_case(): + resp = client.post( + "/webhook/kktix/Sprint", + json=ACTIVATED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + assert resp.status_code == 200 + + with get_conn() as conn: + row = conn.execute( + "SELECT channel FROM events WHERE event_slug = 'test-event'" + ).fetchone() + assert row is not None + assert row["channel"] == "SPRINT" + + +def test_webhook_unauthorized_still_returns_401(): + resp = client.post(SPRINT_URL, json=ACTIVATED_PAYLOAD) + assert resp.status_code == 401 + + with get_conn() as conn: + row = conn.execute( + "SELECT channel FROM webhook_logs ORDER BY id DESC LIMIT 1" + ).fetchone() + assert row is not None + assert row["channel"] == "SPRINT" + + +def test_webhook_unknown_channel_returns_503(monkeypatch): + monkeypatch.delenv("DISCORD_WEBHOOK_UNKNOWNCHANNEL", raising=False) + resp = client.post( + "/webhook/kktix/unknownchannel", + json=ACTIVATED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + assert resp.status_code == 503 + body = resp.json() + assert body["error"] == "channel_not_configured" + assert body["channel"] == "UNKNOWNCHANNEL" + + with get_conn() as conn: + row = conn.execute( + "SELECT channel FROM webhook_logs ORDER BY id DESC LIMIT 1" + ).fetchone() + assert row is not None + assert row["channel"] == "UNKNOWNCHANNEL" + + +def test_webhook_invalid_channel_returns_400(): + resp = client.post( + "/webhook/kktix/bad-name", + json=ACTIVATED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + assert resp.status_code == 400 + body = resp.json() + assert body["error"] == "invalid_channel" + + with get_conn() as conn: + row = conn.execute( + "SELECT channel FROM webhook_logs ORDER BY id DESC LIMIT 1" + ).fetchone() + assert row is not None + assert row["channel"] is None + + +def test_webhook_legacy_path_404(): + resp = client.post( + "/webhook", + json=ACTIVATED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + assert resp.status_code == 404 + + +def test_webhook_cancelled_preserves_channel(): + # First activate an event via SPRINT channel + client.post( + SPRINT_URL, + json=ACTIVATED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + # Then cancel the order + resp = client.post( + SPRINT_URL, + json=CANCELLED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + assert resp.status_code == 200 + + with get_conn() as conn: + event_row = conn.execute( + "SELECT channel FROM events WHERE event_slug = 'test-event'" + ).fetchone() + ticket_row = conn.execute( + "SELECT order_state FROM tickets WHERE order_id = 1001 LIMIT 1" + ).fetchone() + assert event_row["channel"] == "SPRINT" + assert ticket_row["order_state"] == "cancelled" + + +def test_activated_paid_at_stored_as_utc(): + """paid_at in DB should be UTC without timezone offset.""" + resp = client.post( + SPRINT_URL, + json=ACTIVATED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + assert resp.status_code == 200 + + with get_conn() as conn: + row = conn.execute( + "SELECT paid_at FROM tickets WHERE ticket_id = 5001" + ).fetchone() + assert row is not None + paid_at = row["paid_at"] + # Should be UTC: no +/- offset, no Z suffix + assert paid_at == "2026-04-18T02:00:00" + + +def test_cancelled_at_stored_as_utc(): + """cancelled_at in DB should be UTC without timezone offset.""" + # First activate + client.post( + SPRINT_URL, + json=ACTIVATED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + # Then cancel + resp = client.post( + SPRINT_URL, + json=CANCELLED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + assert resp.status_code == 200 + + with get_conn() as conn: + row = conn.execute( + "SELECT cancelled_at FROM tickets WHERE order_id = 1001 LIMIT 1" + ).fetchone() + assert row is not None + cancelled_at = row["cancelled_at"] + # cancelled_at in payload is 2026-04-18T11:00:00+08:00 → UTC 03:00:00 + assert cancelled_at == "2026-04-18T03:00:00" + + +def test_webhook_two_channels_isolated(monkeypatch): + monkeypatch.setenv( + "DISCORD_WEBHOOK_MEETUP", "https://discord.com/api/webhooks/meetup-test" + ) + + sprint_payload = { + "batch_id": "sp001", + "notifications": [ + { + "type": "order_activated_paid", + "event": {"name": "Sprint Event", "slug": "sprint-event"}, + "order": { + "id": 2001, + "state": "activated", + "paid_at": "2026-04-18T10:00:00+08:00", + }, + "contact": { + "name": "Alice", + "email": "alice@example.com", + "mobile": "", + }, + "tickets": [ + { + "id": 6001, + "name": "一般票", + "price_cents": 0, + "price_currency": "TWD", + } + ], + } + ], + } + meetup_payload = { + "batch_id": "mu001", + "notifications": [ + { + "type": "order_activated_paid", + "event": {"name": "Meetup Event", "slug": "meetup-event"}, + "order": { + "id": 3001, + "state": "activated", + "paid_at": "2026-04-18T10:00:00+08:00", + }, + "contact": {"name": "Bob", "email": "bob@example.com", "mobile": ""}, + "tickets": [ + { + "id": 7001, + "name": "一般票", + "price_cents": 0, + "price_currency": "TWD", + } + ], + } + ], + } + + client.post( + "/webhook/kktix/sprint", + json=sprint_payload, + headers={"x-kktix-secret": "test-secret"}, + ) + client.post( + "/webhook/kktix/meetup", + json=meetup_payload, + headers={"x-kktix-secret": "test-secret"}, + ) + + with get_conn() as conn: + sprint_row = conn.execute( + "SELECT channel FROM events WHERE event_slug = 'sprint-event'" + ).fetchone() + meetup_row = conn.execute( + "SELECT channel FROM events WHERE event_slug = 'meetup-event'" + ).fetchone() + + assert sprint_row["channel"] == "SPRINT" + assert meetup_row["channel"] == "MEETUP" + + +def test_first_webhook_schedules_enrichment(monkeypatch): + """First webhook for a new slug → background task enrich_event is scheduled.""" + enrich_calls = [] + + async def fake_enrich(slug): + enrich_calls.append(slug) + + monkeypatch.setattr("argus.kktix.scraper.enrich_event", fake_enrich) + monkeypatch.setattr("argus.kktix.router.enrich_event", fake_enrich) + + resp = client.post( + SPRINT_URL, + json=ACTIVATED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + assert resp.status_code == 200 + # TestClient runs background tasks synchronously, so fake_enrich should have been called + assert enrich_calls == ["test-event"] + + +def test_second_webhook_same_slug_no_enrichment(monkeypatch): + """Second webhook for same slug → rowcount=0 on conflict, no task added.""" + enrich_calls = [] + + async def fake_enrich(slug): + enrich_calls.append(slug) + + monkeypatch.setattr("argus.kktix.scraper.enrich_event", fake_enrich) + monkeypatch.setattr("argus.kktix.router.enrich_event", fake_enrich) + + # First request — creates the event row + client.post( + SPRINT_URL, + json=ACTIVATED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + enrich_calls.clear() + + # Second request — same slug, ON CONFLICT DO NOTHING → rowcount=0 + resp = client.post( + SPRINT_URL, + json=ACTIVATED_PAYLOAD, + headers={"x-kktix-secret": "test-secret"}, + ) + assert resp.status_code == 200 + assert enrich_calls == []