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 == []