10 Commits

Author SHA1 Message Date
tliu93 da236643f2 M2: frontend walkthrough fixes + explicit dev compose stack
frontend / frontend (push) Successful in 2m0s
pytest / test (push) Successful in 1m32s
Post-M2 self-walkthrough polish, batched into one commit.

Map / heat:
- fix heat-layer white-screen crash after login (add layer to map before
  setLatLngs; an off-map leaflet.heat layer has a null _map and throws)
- normalize each heat layer to the densest pixel cell visible in the CURRENT
  viewport (maxZoom:0 so intensity factor f=1) and recompute on moveend/zoomend,
  so sparse poo data reaches red and stays normalized at any zoom level
- dark CARTO basemap tiles when the color scheme is dark

UI:
- dark-mode toggle in the top-right, beside the settings gear
- switch top-right nav (records / theme / settings / logout) to Feather icons
  with hover tooltips
- home: Grafana-style quick time-range presets + back/forward shift buttons,
  placed between the From/To pickers and Apply; fix Select/tooltip z-index
  (Leaflet stacking) and the shift-button height alignment

API client:
- stop flooding GET /api/session with 401s: the session probe and the login
  endpoint own their 401s (no global redirect), which fixes the logout hang and
  the spinning login page

Compose:
- rename docker-compose.override.yml -> docker-compose.dev.yml as an explicit,
  non-auto-layered dev stack (8001, -dev container names, prod-copy ./data DB);
  update tests/test_deployment.py (read dev.yml, tolerate the !override tag) and
  the README "Docker Compose" section

Tests:
- pixel-grid peak counter, time-range presets, heat-layer ordering regression,
  and 401-redirect regression
2026-06-13 15:20:50 +02:00
tliu93 bd09523e94 M2-T13: docs wrap-up + retire frontend constraints + dependency cleanup
- README: add 前端 v2 (React SPA) section (dev/build/codegen/hosting/gates),
  update directory listing, drop stale Jinja descriptions
- architecture-overview: retire '不引入前后端分离' constraint; reflect SPA + JSON API
- roadmap: mark M2 done
- remove orphaned jinja2 dependency (recompile requirements*.txt; no other churn)
- delete empty tests/test_auth.py stub; drop dead _extract_csrf_token in test_api_data
- verified image still builds and app imports with the slimmer deps
2026-06-13 15:20:50 +02:00
tliu93 53f1245d83 docs(m2): mark M2-T12 done 2026-06-13 15:20:50 +02:00
tliu93 51f712f602 M2-T12: multi-stage Dockerfile (node build -> python runtime) + frontend CI
- Dockerfile: node:22-slim stage runs npm ci + npm run build; python runtime
  stage COPY --from copies dist to /app/frontend/dist (matches SPA_DIST_DIR);
  runtime image has no node
- .dockerignore: exclude frontend/node_modules and frontend/dist from context
- .github/workflows/frontend.yml: npm ci + codegen-sync + lint/typecheck/test/build
- tests/test_deployment.py: skip COPY --from sources in the context-existence
  check; assert the multi-stage frontend build wiring
- verified with a real docker build (image serves SPA, no node at runtime)
2026-06-13 15:20:50 +02:00
tliu93 f8b1e5fc71 docs(m2): mark M2-T11 done 2026-06-13 15:20:50 +02:00
tliu93 a9830c42d8 M2-T11: serve React SPA from FastAPI and remove Jinja pages
- app/main.py serves the SPA build (SPA_DIST_DIR, default frontend/dist):
  mounts /assets and a GET catch-all returning index.html for client routes;
  catch-all 404s on /api/*, never swallows /docs, /openapi.json, /static, assets,
  ingestion/ticktick/status; skips SPA serving when dist absent (backend-only CI)
- delete app/api/routes/pages.py, app/api/routes/auth.py, app/templates/
  (all replaced by /api/* + SPA; auth service layer kept)
- remove/replace Jinja page tests (JSON coverage already in test_api_*);
  add tests/test_spa_hosting.py for the fallback contract
- regenerate openapi/ (Jinja paths gone) and frontend schema.d.ts
2026-06-13 15:20:50 +02:00
tliu93 8aa7316b26 docs(m2): mark M2-T09 done 2026-06-13 15:20:50 +02:00
tliu93 32d93bba2a M2-T09: build data visualization UI (heatmap map as home page)
- self-contained RecordsMap (only module importing leaflet/react-leaflet/
  leaflet.heat/leaflet.markercluster); OSM tiles, swappable behind clean props
- heatmap layers for location + poo (primary); time-range selector fetches
  only the window (locations server-filtered; poo client-filtered)
- toggleable scatter layer with marker clustering; point-select reuses T10's
  edit/delete modals + hooks; query-key prefixes refresh map on mutation
- pure map logic isolated + unit-tested; leaflet mocked in component tests
- responsive layout; typed client only
2026-06-13 15:20:50 +02:00
tliu93 0d988a9b28 docs(m2): mark M2-T10 done 2026-06-13 15:20:50 +02:00
tliu93 ef7ea6b971 M2-T10: build records management UI (paginated lists + single-record CRUD)
- reusable src/records/ module: useUpdate/useDelete Poo+Location hooks
  (encodeURIComponent PK, prefix-based query invalidation), EditPooModal,
  EditLocationModal, ConfirmDeleteModal — exported for the map (T09) to reuse
- RecordsPage (/records): paginated poo + location tables (page size 100),
  edit + delete-with-confirm, refresh on success
- query keys ['poo']/['locations'] so map and list invalidations cross-cut
- typed client only; vitest tests
2026-06-13 15:20:50 +02:00
56 changed files with 4217 additions and 2198 deletions
+3
View File
@@ -8,3 +8,6 @@ data
openapi openapi
src src
# Frontend host build artifacts — built inside the node stage, not needed from context
frontend/node_modules
frontend/dist
+49
View File
@@ -0,0 +1,49 @@
name: frontend
on:
push:
branches:
- "**"
pull_request:
workflow_dispatch:
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Check codegen is in sync
working-directory: frontend
run: |
npm run codegen
git diff --exit-code src/api/schema.d.ts
- name: Lint
working-directory: frontend
run: npm run lint
- name: Type-check
working-directory: frontend
run: npm run typecheck
- name: Test
working-directory: frontend
run: npm run test
- name: Build
working-directory: frontend
run: npm run build
+15
View File
@@ -1,3 +1,15 @@
# Stage 1: build the React SPA
FROM node:22-slim AS frontend-build
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: python runtime (no node)
FROM python:3.12-slim FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -16,6 +28,9 @@ COPY docker ./docker
COPY README.md ./ COPY README.md ./
RUN mkdir -p /app/data RUN mkdir -p /app/data
# Copy the built SPA dist from the frontend-build stage
COPY --from=frontend-build /frontend/dist ./frontend/dist
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/app/docker/entrypoint.sh"] ENTRYPOINT ["/app/docker/entrypoint.sh"]
+76 -19
View File
@@ -4,7 +4,7 @@
当前系统已经包含: 当前系统已经包含:
- FastAPI Web 应用与服务端模板页面 - FastAPI Web 应用React SPA 前端 + JSON API
- SQLite + SQLAlchemy + Alembic 的单库结构 - SQLite + SQLAlchemy + Alembic 的单库结构
- username/password + server-side session 鉴权 - username/password + server-side session 鉴权
- runtime config 页面与 app DB 持久化 - runtime config 页面与 app DB 持久化
@@ -47,11 +47,13 @@ python -m scripts.run_migrations
主要目录如下: 主要目录如下:
- `app/`: FastAPI 应用代码 - `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型)
- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine
- `alembic_app/`: App DB 的 Alembic migration 环境(同时管理 `location` / `poo_records` 表) - `alembic_app/`: App DB 的 Alembic migration 环境(同时管理 `location` / `poo_records` 表)
- `tests/`: pytest 测试 - `tests/`: pytest 测试
- `docs/`: 当前系统说明文档 - `docs/`: 当前系统说明文档
- `scripts/`: 辅助脚本,例如 OpenAPI 导出 - `scripts/`: 辅助脚本,例如 OpenAPI 导出
- `openapi/`: OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),纳入版本控制
## 依赖管理 ## 依赖管理
@@ -112,11 +114,62 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
启动后可访问: 启动后可访问:
- 应用首页:`http://localhost:8000/` - 应用首页React SPA`http://localhost:8000/`
- 健康检查:`http://localhost:8000/status` - 健康检查:`http://localhost:8000/status`
- Swagger UI`http://localhost:8000/docs` - Swagger UI`http://localhost:8000/docs`
- ReDoc`http://localhost:8000/redoc` - ReDoc`http://localhost:8000/redoc`
## 前端 v2React SPA
M2 用 React SPA 取代了原有 Jinja 服务端模板,由 FastAPI 同源托管(同一容器、同一 origin)。
### 技术栈
- **Vite + React + TypeScript + Mantine**(组件库)
- **TanStack Query**(数据请求/缓存)
- **Leaflet / react-leaflet**(地图与热力图)
- **openapi-typescript + openapi-fetch**(类型化 API client,由 `openapi/openapi.json` 生成)
### 本地开发(前端)
前端开发服务器会把 `/api``/location``/poo``/public-ip``/homeassistant``/ticktick``/status` 等路径代理到后端 FastAPI`:8000`)。
```bash
cd frontend
npm install
npm run dev # 启动 Vite dev server(默认 :5173),代理后端
```
### 构建
```bash
cd frontend
npm run build # 产出 frontend/dist
```
FastAPI 启动时若 `frontend/dist/index.html` 存在,则自动挂载该目录,并对非 `/api` 路径做 SPA fallback(返回 `index.html`)。该路径可通过环境变量 `SPA_DIST_DIR` 覆盖(默认值为 `frontend/dist`,与多阶段 Dockerfile 中 `COPY``/app/frontend/dist` 一致)。
### 类型化 API Client
前端 API client 由后端 OpenAPI schema 自动生成:
```bash
cd frontend
npm run codegen # 从 ../openapi/openapi.json 生成 src/api/schema.d.ts
```
生成物(`src/api/schema.d.ts`)已提交入库,CI 会校验它与 `openapi/openapi.json` 保持同步。
### 前端校验闸门
```bash
cd frontend
npm run lint # ESLint
npm run typecheck # TypeScript 类型检查
npm run test # Vitest 单元测试
npm run build # 构建,确认产出 dist
```
## 数据库与 Alembic ## 数据库与 Alembic
当前使用单一 SQLite 数据库文件: 当前使用单一 SQLite 数据库文件:
@@ -142,9 +195,9 @@ python -m scripts.migrate_legacy_data
- 认证模型:`username/password` - 认证模型:`username/password`
- 会话模型:server-side session + cookie - 会话模型:server-side session + cookie
- 当前主要受保护页面:`/config` - 当前受保护入口:React SPA`/` 等客户端路由)调用 `/api/*` JSON 端点
- 当前公开页面:`/login` - 当前公开页面:`/login`SPA 登录页)
- 当前公开 API现有业务 API 暂未在这一轮统一收口到 auth 下 - 当前公开 API裸 ingestion 端点(`/location/record``/poo/record` 等设备调用端点)暂未收口到 session 保护(M3 再做)
安全实现的当前边界: 安全实现的当前边界:
@@ -152,7 +205,7 @@ python -m scripts.migrate_legacy_data
- session cookie 使用 `HttpOnly` - session cookie 使用 `HttpOnly`
- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启 - `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启
- `SameSite=Lax` - `SameSite=Lax`
- 登录表单和登出表单都有基础 CSRF 防护 - 写请求(POST/PUT/PATCH/DELETE)需携带 `X-CSRF-Token` headerSameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对)
首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用: 首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用:
@@ -166,12 +219,14 @@ python -m scripts.migrate_legacy_data
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。 首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
当前前端主要有两条页面路径 React SPA 主要页面路由(客户端路由,均由 FastAPI fallback 到 `index.html`
- `/login` - `/login`:登录页
- `/config` - `/`:首页(地图热力图主视图)
- `/config`:配置页(取代原 Jinja `/config`
- `/records`:记录管理列表页
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config` 无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后进入 SPA 首页(`/`
## Config 持久化 ## Config 持久化
@@ -230,8 +285,8 @@ python -m scripts.migrate_legacy_data
当前系统已经提供最小可用的 SMTP 能力: 当前系统已经提供最小可用的 SMTP 能力:
- SMTP 配置可在 `/config` 页面填写并保存到 `app_config` - SMTP 配置可在 React SPA `/config` 页面填写并保存到 `app_config`(通过 `PUT /api/config`
- 可通过 config 页面发送测试邮件 - 可通过 config 页面发送测试邮件`POST /api/config/smtp/test`
- 邮件 `From` 头支持显示名,例如 `Home Automation <sender@example.com>` - 邮件 `From` 头支持显示名,例如 `Home Automation <sender@example.com>`
当前 SMTP 配置项包括: 当前 SMTP 配置项包括:
@@ -283,18 +338,20 @@ python scripts/export_openapi.py
当前 Compose 分成两层: 当前 Compose 分成两层:
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取 - `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取(暴露 8881
- `docker-compose.override.yml`仅为本地开发追加 `build: .` - `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project /
容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存
本地开发启动方式: 本地开发启动方式(显式叠加 dev 层)
```bash ```bash
docker compose up -d --build docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
``` ```
上面的命令会自动叠加 `docker-compose.override.yml`,因此本地仍然会按当前工作目录重新 build。 dev 层刻意不沿用 `docker-compose.override.yml` 这种会被 `docker compose up` 自动叠加的文件名,
因此默认的 `docker compose up` 只用生产基础文件,不会把开发端口 / 配置误带到生产。
如果要按生产方式直接从 registry 拉取并启动,显式只使用基础 compose 文件: 如果要按生产方式直接从 registry 拉取并启动,使用基础 compose 文件:
```bash ```bash
docker compose -f docker-compose.yml pull docker compose -f docker-compose.yml pull
-234
View File
@@ -1,234 +0,0 @@
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.config import Settings
from app.dependencies import get_app_settings, get_db, get_current_auth_session
from app.services.auth import (
AuthenticatedSession,
authenticate_user,
change_password,
create_session,
AuthPasswordChangeError,
issue_login_csrf_token,
revoke_session,
validate_csrf_token,
)
from app.services.config_page import build_config_sections, is_ticktick_oauth_ready
logger = logging.getLogger(__name__)
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["auth"])
LOGIN_CSRF_COOKIE_NAME = "login_csrf"
@router.get("/login", response_class=HTMLResponse)
def login_page(
request: Request,
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is not None:
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
csrf_token = issue_login_csrf_token()
response = templates.TemplateResponse(
request,
"login.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"csrf_token": csrf_token,
"error_message": None,
},
)
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
return response
@router.post("/login", response_class=HTMLResponse)
def login_submit(
request: Request,
username: str = Form(),
password: str = Form(),
csrf_token: str = Form(),
session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
) -> Response:
cookie_csrf_token = request.cookies.get(LOGIN_CSRF_COOKIE_NAME)
if not validate_csrf_token(expected=cookie_csrf_token, actual=csrf_token):
logger.warning("Rejected login attempt due to CSRF validation failure")
return _render_login_error(
request,
settings=settings,
status_code=status.HTTP_400_BAD_REQUEST,
error_message="invalid login request",
)
user = authenticate_user(session, username=username, password=password)
if user is None:
return _render_login_error(
request,
settings=settings,
status_code=status.HTTP_401_UNAUTHORIZED,
error_message="invalid username or password",
)
auth_session, raw_token = create_session(session, user=user, settings=settings)
response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
response.set_cookie(
key=settings.auth_session_cookie_name,
value=raw_token,
max_age=settings.auth_session_ttl_hours * 3600,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/",
)
logger.info("Created authenticated session for user '%s'", user.username)
return response
@router.post("/config/change-password", response_class=HTMLResponse)
def change_password_submit(
request: Request,
current_password: str = Form(),
new_password: str = Form(),
confirm_password: str = Form(),
csrf_token: str = Form(),
session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
logger.warning("Rejected password change attempt due to CSRF validation failure")
return _render_config_page(
request,
settings=settings,
auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="invalid password change request",
)
try:
change_password(
session,
user=current_auth.user,
current_password=current_password,
new_password=new_password,
confirm_password=confirm_password,
)
except AuthPasswordChangeError as exc:
logger.info(
"Rejected password change for user '%s': %s",
current_auth.user.username,
exc,
)
return _render_config_page(
request,
settings=settings,
auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="password change failed",
)
logger.info("Password updated for user '%s'", current_auth.user.username)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/logout")
def logout(
request: Request,
csrf_token: str = Form(),
session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is not None and validate_csrf_token(
expected=current_auth.session.csrf_token, actual=csrf_token
):
revoke_session(session, auth_session=current_auth.session)
logger.info("Revoked authenticated session for user '%s'", current_auth.user.username)
else:
logger.warning("Rejected logout request due to missing session or invalid CSRF token")
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(settings.auth_session_cookie_name, path="/")
return response
def _render_login_error(
request: Request,
*,
settings: Settings,
status_code: int,
error_message: str,
) -> HTMLResponse:
csrf_token = issue_login_csrf_token()
response = templates.TemplateResponse(
request,
"login.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"csrf_token": csrf_token,
"error_message": error_message,
},
status_code=status_code,
)
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
return response
def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: str) -> None:
response.set_cookie(
key=LOGIN_CSRF_COOKIE_NAME,
value=token,
max_age=1800,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/login",
)
def _render_config_page(
request: Request,
*,
settings: Settings,
auth_db_session: Session,
current_auth: AuthenticatedSession,
status_code: int,
password_change_error: str | None,
) -> HTMLResponse:
return templates.TemplateResponse(
request,
"config.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": password_change_error,
"config_error": None,
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_oauth_notice": None,
"ticktick_oauth_error": None,
},
status_code=status_code,
)
-240
View File
@@ -1,240 +0,0 @@
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from app.config import Settings, get_settings
from app.dependencies import get_app_settings, get_db, get_current_auth_session
from app.services.auth import AuthenticatedSession
from app.services.config_page import (
ConfigSaveError,
build_config_sections,
is_ticktick_oauth_ready,
save_config_updates,
)
from app.services.email import EmailConfigurationError, EmailDeliveryError, is_smtp_ready, send_smtp_test_email
from sqlalchemy.orm import Session
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["pages"])
logger = logging.getLogger(__name__)
def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]:
if status_value == "success":
return "TickTick authorization completed successfully.", None
if status_value == "invalid-state":
return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again."
if status_value == "invalid-callback":
return None, "TickTick authorization callback was missing required parameters."
if status_value == "failed":
return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI."
return None, None
def _smtp_test_notice(status_value: str | None) -> tuple[str | None, str | None]:
if status_value == "success":
return "SMTP test email sent successfully.", None
if status_value == "config-error":
return None, "SMTP test failed. Check required SMTP settings before sending a test email."
if status_value == "failed":
return None, "SMTP test failed. Check saved SMTP settings and server reachability."
return None, None
def _build_config_context(
*,
auth_db_session: Session,
settings: Settings,
current_auth: AuthenticatedSession,
config_saved: bool,
config_error: str | None,
password_change_error: str | None,
ticktick_oauth_notice: str | None,
ticktick_oauth_error: str | None,
smtp_test_notice: str | None,
smtp_test_error: str | None,
) -> dict[str, object]:
return {
"app_name": settings.app_name,
"app_env": settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": password_change_error,
"config_error": config_error,
"config_saved": config_saved,
"config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_oauth_notice": ticktick_oauth_notice,
"ticktick_oauth_error": ticktick_oauth_error,
"smtp_test_ready": is_smtp_ready(settings),
"smtp_test_notice": smtp_test_notice,
"smtp_test_error": smtp_test_error,
}
@router.get("/", response_class=HTMLResponse)
def home(
request: Request,
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin", response_class=HTMLResponse)
def admin_redirect(
request: Request,
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/config", response_class=HTMLResponse)
def config_page(
request: Request,
auth_db_session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice(
request.query_params.get("ticktick_oauth")
)
smtp_test_notice, smtp_test_error = _smtp_test_notice(request.query_params.get("smtp_test"))
context = _build_config_context(
auth_db_session=auth_db_session,
settings=settings,
current_auth=current_auth,
config_saved=request.query_params.get("saved") == "1",
config_error=None,
password_change_error=None,
ticktick_oauth_notice=ticktick_oauth_notice,
ticktick_oauth_error=ticktick_oauth_error,
smtp_test_notice=smtp_test_notice,
smtp_test_error=smtp_test_error,
)
return templates.TemplateResponse(request, "config.html", context)
@router.post("/config", response_class=HTMLResponse)
async def config_submit(
request: Request,
auth_db_session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
form = await request.form()
csrf_token = form.get("csrf_token")
if csrf_token != current_auth.session.csrf_token:
logger.warning("Rejected config update due to CSRF validation failure")
context = _build_config_context(
auth_db_session=auth_db_session,
settings=settings,
current_auth=current_auth,
config_saved=False,
config_error="invalid config update request",
password_change_error=None,
ticktick_oauth_notice=None,
ticktick_oauth_error=None,
smtp_test_notice=None,
smtp_test_error=None,
)
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
save_config_updates(auth_db_session, dict(form), settings)
except ConfigSaveError:
logger.warning("Rejected config update due to invalid submitted values")
refreshed_settings = get_settings()
context = _build_config_context(
auth_db_session=auth_db_session,
settings=refreshed_settings,
current_auth=current_auth,
config_saved=False,
config_error="invalid config submission",
password_change_error=None,
ticktick_oauth_notice=None,
ticktick_oauth_error=None,
smtp_test_notice=None,
smtp_test_error=None,
)
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/config/smtp/test", response_class=HTMLResponse)
async def smtp_test_submit(
request: Request,
auth_db_session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
form = await request.form()
csrf_token = form.get("csrf_token")
if csrf_token != current_auth.session.csrf_token:
logger.warning("Rejected SMTP test due to CSRF validation failure")
context = _build_config_context(
auth_db_session=auth_db_session,
settings=settings,
current_auth=current_auth,
config_saved=False,
config_error=None,
password_change_error=None,
ticktick_oauth_notice=None,
ticktick_oauth_error=None,
smtp_test_notice=None,
smtp_test_error="invalid SMTP test request",
)
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
send_smtp_test_email(settings)
except EmailConfigurationError as exc:
logger.warning("SMTP test email rejected due to configuration: %s", exc)
return RedirectResponse(
url="/config?smtp_test=config-error",
status_code=status.HTTP_303_SEE_OTHER,
)
except EmailDeliveryError as exc:
logger.warning("SMTP test email failed: %s", exc)
return RedirectResponse(
url="/config?smtp_test=failed",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
url="/config?smtp_test=success",
status_code=status.HTTP_303_SEE_OTHER,
)
+49 -5
View File
@@ -1,7 +1,10 @@
import logging
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
@@ -11,8 +14,7 @@ from app import models # noqa: F401
from app.api.routes.api.config import router as api_config_router from app.api.routes.api.config import router as api_config_router
from app.api.routes.api.data import router as api_data_router from app.api.routes.api.data import router as api_data_router
from app.api.routes.api.session import router as api_session_router from app.api.routes.api.session import router as api_session_router
from app.api.routes.auth import router as auth_router from app.api.routes import status
from app.api.routes import pages, status
from app.db import get_session_local from app.db import get_session_local
from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.homeassistant import router as homeassistant_router
from app.api.routes.location import router as location_router from app.api.routes.location import router as location_router
@@ -25,6 +27,17 @@ from app.services.config_page import seed_missing_config_from_bootstrap, sync_ap
from app.services.public_ip import check_public_ipv4_and_notify from app.services.public_ip import check_public_ipv4_and_notify
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
logger = logging.getLogger(__name__)
_REPO_ROOT = Path(__file__).resolve().parents[1]
def _get_spa_dist_dir() -> Path:
env_val = os.environ.get("SPA_DIST_DIR")
if env_val:
return Path(env_val)
return _REPO_ROOT / "frontend" / "dist"
def _run_scheduled_public_ip_check() -> None: def _run_scheduled_public_ip_check() -> None:
session_local = get_session_local() session_local = get_session_local()
@@ -92,8 +105,6 @@ def create_app() -> FastAPI:
app.mount("/static", StaticFiles(directory=static_dir), name="static") app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(status.router) app.include_router(status.router)
app.include_router(auth_router)
app.include_router(pages.router)
app.include_router(api_config_router) app.include_router(api_config_router)
app.include_router(api_data_router) app.include_router(api_data_router)
app.include_router(api_session_router) app.include_router(api_session_router)
@@ -102,6 +113,39 @@ def create_app() -> FastAPI:
app.include_router(poo_router) app.include_router(poo_router)
app.include_router(public_ip_router) app.include_router(public_ip_router)
app.include_router(ticktick_router) app.include_router(ticktick_router)
# SPA hosting: mount frontend/dist if it exists and has index.html.
# If the SPA dist is absent (e.g. backend-only CI), skip SPA serving entirely
# so that pytest stays green with only the API routes registered.
spa_dist = _get_spa_dist_dir()
spa_index = spa_dist / "index.html"
if spa_dist.is_dir() and spa_index.is_file():
spa_assets = spa_dist / "assets"
if spa_assets.is_dir():
app.mount("/assets", StaticFiles(directory=spa_assets), name="spa-assets")
# Resolve the dist root once so the containment check is fast and consistent.
_spa_root = spa_dist.resolve()
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str, request: Request) -> FileResponse: # noqa: RUF029
# Explicit 404 for unmatched /api/* — never return index.html for API paths.
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="not found")
# Resolve candidate to an absolute path and verify it stays within the SPA
# dist root. Without this check, URL-encoded ".." sequences (e.g. "..%2f")
# bypass Starlette's path parameter handling and allow arbitrary file reads.
candidate = (spa_dist / full_path).resolve()
if candidate.is_file() and candidate.is_relative_to(_spa_root):
return FileResponse(candidate)
# For any path outside the dist root, or for SPA client routes that don't
# correspond to a real file, return index.html so the SPA router handles it.
return FileResponse(spa_index)
else:
logger.warning(
"SPA dist not found at %s — SPA hosting disabled (API-only mode).", spa_dist
)
return app return app
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main class="shell">
{% block content %}{% endblock %}
</main>
</body>
</html>
-139
View File
@@ -1,139 +0,0 @@
{% extends "base.html" %}
{% block title %}Config · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">Configuration</p>
<h1>Config</h1>
{% if force_password_change %}
<div class="alert">
首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。
</div>
{% endif %}
{% if password_change_error %}
<div class="alert">{{ password_change_error }}</div>
{% endif %}
{% if config_error %}
<div class="alert">{{ config_error }}</div>
{% endif %}
{% if config_saved %}
<div class="notice">config saved to the app database. Some changes may require an app restart.</div>
{% endif %}
{% if ticktick_oauth_error %}
<div class="alert">{{ ticktick_oauth_error }}</div>
{% endif %}
{% if ticktick_oauth_notice %}
<div class="notice">{{ ticktick_oauth_notice }}</div>
{% endif %}
{% if smtp_test_error %}
<div class="alert">{{ smtp_test_error }}</div>
{% endif %}
{% if smtp_test_notice %}
<div class="notice">{{ smtp_test_notice }}</div>
{% endif %}
<div class="meta single-column">
<div>
<dt>当前用户</dt>
<dd>admin</dd>
</div>
</div>
<section class="config-block">
<h2>Change Password</h2>
<form class="auth-form" method="post" action="/config/change-password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Current Password</span>
<input type="password" name="current_password" autocomplete="current-password" required>
</label>
<label>
<span>New Password</span>
<input type="password" name="new_password" autocomplete="new-password" required>
</label>
<label>
<span>Confirm New Password</span>
<input type="password" name="confirm_password" autocomplete="new-password" required>
</label>
<button type="submit">修改密码</button>
</form>
</section>
<section class="config-block">
<h2>Config</h2>
<form class="config-form" method="post" action="/config">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for section in config_sections %}
<fieldset class="config-section">
<legend>{{ section.name }}</legend>
{% for field in section.fields %}
<label>
<span>{{ field.label }}</span>
{% if field.secret %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="" placeholder="leave blank to keep current value">
<small>{% if field.configured %}configured{% else %}not configured{% endif %}</small>
{% else %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="{{ field.value }}">
{% endif %}
</label>
{% endfor %}
{% if section.name == "TickTick" %}
<div class="integration-action-row">
<div>
<p class="integration-action-title">TickTick OAuth</p>
<p class="integration-action-copy">Redirect URI: {{ ticktick_redirect_uri or "configure APP_HOSTNAME to generate the callback URI" }}</p>
{% if ticktick_oauth_ready %}
<p class="integration-action-copy">Use the saved TickTick client settings to start the authorization flow.</p>
{% else %}
<p class="integration-action-copy">Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth.</p>
{% endif %}
</div>
{% if ticktick_oauth_ready %}
<a class="button-link" href="/ticktick/auth/start">Authorize TickTick</a>
{% else %}
<span class="button-link disabled" aria-disabled="true">Authorize TickTick</span>
{% endif %}
</div>
{% endif %}
{% if section.name == "SMTP" %}
<div class="integration-action-row">
<div>
<p class="integration-action-title">SMTP Test Email</p>
<p class="integration-action-copy">Save the SMTP settings first, then send a simple plaintext test email to the configured recipient.</p>
</div>
{% if smtp_test_ready %}
<button type="submit" formaction="/config/smtp/test" formmethod="post">Send SMTP Test</button>
{% else %}
<span class="button-link disabled" aria-disabled="true">Send SMTP Test</span>
{% endif %}
</div>
{% endif %}
</fieldset>
{% endfor %}
<button type="submit">Save Config</button>
</form>
</section>
<form class="logout-form" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">登出</button>
</form>
</section>
{% endblock %}
-36
View File
@@ -1,36 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ app_name }}{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">Python Rewrite Skeleton</p>
<h1>{{ app_name }}</h1>
<p class="lead">
这是当前 Go 后端的 Python 重构基础骨架。此阶段仅提供应用入口、配置、数据库、
测试、模板和容器化基础,不包含业务逻辑迁移。
</p>
<dl class="meta">
<div>
<dt>运行环境</dt>
<dd>{{ app_env }}</dd>
</div>
<div>
<dt>健康检查</dt>
<dd><a href="/status">/status</a></dd>
</div>
<div>
<dt>OpenAPI</dt>
<dd><a href="/docs">/docs</a></dd>
</div>
<div>
<dt>登录</dt>
<dd><a href="/login">/login</a></dd>
</div>
<div>
<dt>Notion</dt>
<dd>{{ notion_status }}</dd>
</div>
</dl>
</section>
{% endblock %}
-33
View File
@@ -1,33 +0,0 @@
{% extends "base.html" %}
{% block title %}登录 · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel auth-panel">
<p class="eyebrow">Authentication</p>
<h1>登录</h1>
<p class="lead">
登录成功后会进入受保护的 config 页面。
</p>
{% if error_message %}
<div class="alert">{{ error_message }}</div>
{% endif %}
<form class="auth-form" method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Username</span>
<input type="text" name="username" autocomplete="username" required>
</label>
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit">登录</button>
</form>
</section>
{% endblock %}
+1 -5
View File
@@ -53,14 +53,10 @@ idna==3.11
# httpx # httpx
iniconfig==2.3.0 iniconfig==2.3.0
# via pytest # via pytest
jinja2==3.1.6
# via -r requirements.in
mako==1.3.11 mako==1.3.11
# via alembic # via alembic
markupsafe==3.0.3 markupsafe==3.0.3
# via # via mako
# jinja2
# mako
packaging==26.1 packaging==26.1
# via # via
# build # build
+28
View File
@@ -0,0 +1,28 @@
# Local dev override — use explicitly:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
# Isolated from the production stack so both can run on this host at once:
# - distinct compose project name (separate network/grouping)
# - distinct container names (-dev suffix; Docker rejects duplicate names)
# - distinct image tag (local build doesn't clobber the prod :latest tag)
name: home-automation-dev
services:
migration:
build: .
image: home-automation:dev
container_name: home-automation-migration-dev
environment:
# In-container path for the mounted ./data volume (./data -> /app/data).
# Overrides the host-absolute APP_DATABASE_URL in .env for local compose runs.
APP_DATABASE_URL: "sqlite:////app/data/app.db"
app:
build: .
image: home-automation:dev
container_name: home-automation-app-dev
# Publish on 8001 for dev. `!override` REPLACES the base ports list instead of
# appending to it, so the dev stack does NOT also bind the production 8881.
ports: !override
- "127.0.0.1:8001:8000"
environment:
APP_DATABASE_URL: "sqlite:////app/data/app.db"
-6
View File
@@ -1,6 +0,0 @@
services:
migration:
build: .
app:
build: .
+16 -9
View File
@@ -29,10 +29,8 @@
- 通用依赖注入 - 通用依赖注入
- `api/` - `api/`
- HTTP routes - HTTP routes
- 当前已迁入 `/login``/logout``/admin` - `api/routes/api/`JSON API`/api/*` 前缀),供 React SPA 调用:会话/鉴权、配置读写、数据查询、记录 CRUD
- 当前已迁入 `GET /public-ip/check` - 裸 ingestion 端点:`GET /public-ip/check``POST /homeassistant/publish``POST /poo/record``GET /poo/latest`、TickTick OAuth 等
- 当前已迁入 `POST /homeassistant/publish` 第一版入口
- 当前已迁入 `POST /poo/record``GET /poo/latest`
- `models/` - `models/`
- SQLAlchemy models - SQLAlchemy models
- 所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均落在单一 `app.db` - 所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均落在单一 `app.db`
@@ -46,8 +44,6 @@
- `integrations/` - `integrations/`
- 外部系统适配层 - 外部系统适配层
- 当前已迁入 Home Assistant outbound adapter - 当前已迁入 Home Assistant outbound adapter
- `templates/`
- Jinja2 模板
- `static/` - `static/`
- 极简静态资源 - 极简静态资源
@@ -63,15 +59,26 @@ pytest 测试目录。后续可以在这里自然扩展:
- mock tests - mock tests
- integration tests - integration tests
### `frontend/`
React SPA 前端(M2 引入)。Vite + React + TypeScript + Mantine,由 FastAPI 同源托管。
- `src/`React 源码
- `src/api/`:由 `openapi/openapi.json` 生成的类型化 client`schema.d.ts`+ fetch 封装
- `dist/``npm run build` 产物,由 FastAPI 的 `SPA_DIST_DIR` 挂载并对非 `/api` 路径做 fallback
### `scripts/` ### `scripts/`
辅助脚本目录。当前包含 OpenAPI 导出脚本。 辅助脚本目录。当前包含 OpenAPI 导出脚本`export_openapi.py`)与数据层辅助脚本
### `openapi/`
OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),由 `python scripts/export_openapi.py` 生成,纳入版本控制。前端 codegen 以此为契约源。
## 当前约束 ## 当前约束
- 当前只搭骨架,不迁业务逻辑
- 当前数据库继续使用 SQLite - 当前数据库继续使用 SQLite
- 当前不引入前后端分离 - ~~当前不引入前后端分离~~ **已退役(M2**:现为 React SPA + JSON `/api` 层,由 FastAPI 同源托管
- 当前不设计 Notion 模块 - 当前不设计 Notion 模块
- 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象 - 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象
+5 -5
View File
@@ -196,16 +196,16 @@
- **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。 - **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。
### M2-T09 — 数据可视化 UI(热力图为主的地图) ### M2-T09 — 数据可视化 UI(热力图为主的地图)
- **Status**: `todo` · **Depends**: M2-T06(数据来自 T03 - **Status**: `done` · **Depends**: M2-T06(数据来自 T03
- **Context**: 接管 Grafana 原职责,且**首页主视图就是这张地图**。优先级:**① 热力图(最重要)② 时间范围选择器(必须)③ 散点点位(辅助,主要服务编辑/删除)**。location:去过哪的密度;poo:狗最爱在哪拉。 - **Context**: 接管 Grafana 原职责,且**首页主视图就是这张地图**。优先级:**① 热力图(最重要)② 时间范围选择器(必须)③ 散点点位(辅助,主要服务编辑/删除)**。location:去过哪的密度;poo:狗最爱在哪拉。
- **Acceptance**: 首页渲染热力图(location / poo);**时间范围选择器生效、只取窗口内数据**(不拉全量);散点层可切换、点选某点可进入编辑/删除(接 T10/T04);location 点多时聚合;响应式(手机浏览器可用);前端闸门全绿。 - **Acceptance**: 首页渲染热力图(location / poo);**时间范围选择器生效、只取窗口内数据**(不拉全量);散点层可切换、点选某点可进入编辑/删除(接 T10/T04);location 点多时聚合;响应式(手机浏览器可用);前端闸门全绿。
### M2-T10 — 记录管理 UI(按需展示 + 增删改) ### M2-T10 — 记录管理 UI(按需展示 + 增删改)
- **Status**: `todo` · **Depends**: M2-T06CRUD 来自 T04 - **Status**: `done` · **Depends**: M2-T06CRUD 来自 T04
- **Acceptance**: 列表分页展示 poo/location;可编辑、可删除单条并即时刷新;删除有二次确认;前端闸门全绿。 - **Acceptance**: 列表分页展示 poo/location;可编辑、可删除单条并即时刷新;删除有二次确认;前端闸门全绿。
### M2-T11 — FastAPI 托管 SPA + 移除 Jinja ### M2-T11 — FastAPI 托管 SPA + 移除 Jinja
- **Status**: `todo` · **Depends**: M2-T07, T08, T09, T10 - **Status**: `done` · **Depends**: M2-T07, T08, T09, T10
- **Files**: `modify app/main.py`(挂载 SPA 静态目录 + 非 `/api` 路径回退 `index.html`);`delete app/templates/``app/api/routes/pages.py`(功能对齐后);`modify tests`(移除 Jinja 页面测试,新增 SPA fallback 测试) - **Files**: `modify app/main.py`(挂载 SPA 静态目录 + 非 `/api` 路径回退 `index.html`);`delete app/templates/``app/api/routes/pages.py`(功能对齐后);`modify tests`(移除 Jinja 页面测试,新增 SPA fallback 测试)
- **Acceptance**: - **Acceptance**:
- [ ] `/config` 等路径返回 SPA`index.html`),`/api/*` 不被 fallback 吞掉,`/static`/资源正常。 - [ ] `/config` 等路径返回 SPA`index.html`),`/api/*` 不被 fallback 吞掉,`/static`/资源正常。
@@ -214,7 +214,7 @@
- **Reviewer**: fallback 不拦截 `/api``/docs``/openapi.json`、静态资源;未登录访问 API 仍 401(不是被 SPA 壳吞掉)。 - **Reviewer**: fallback 不拦截 `/api``/docs``/openapi.json`、静态资源;未登录访问 API 仍 401(不是被 SPA 壳吞掉)。
### M2-T12 — 多阶段 Dockerfile + CI/compose ### M2-T12 — 多阶段 Dockerfile + CI/compose
- **Status**: `todo` · **Depends**: M2-T11 - **Status**: `done` · **Depends**: M2-T11
- **Files**: `modify Dockerfile`node build 阶段 → 拷 `dist` 进 python 镜像);`modify .github/workflows/*`(加前端 build/lint/typecheck);`modify tests/test_deployment.py`(镜像断言更新) - **Files**: `modify Dockerfile`node build 阶段 → 拷 `dist` 进 python 镜像);`modify .github/workflows/*`(加前端 build/lint/typecheck);`modify tests/test_deployment.py`(镜像断言更新)
- **Acceptance**: - **Acceptance**:
- [ ] 镜像构建成功且运行镜像不含 node 运行时。 - [ ] 镜像构建成功且运行镜像不含 node 运行时。
@@ -222,7 +222,7 @@
- [ ] 校验闸门全绿。 - [ ] 校验闸门全绿。
### M2-T13 — 文档 + OpenAPI 收尾 ### M2-T13 — 文档 + OpenAPI 收尾
- **Status**: `todo` · **Depends**: M2-T12 - **Status**: `done` · **Depends**: M2-T12
- **Acceptance**: README 增"前端 v2"段(开发/构建说明);architecture 退役"不前后端分离"约束;roadmap 勾选 M2`openapi/` 已同步入库。 - **Acceptance**: README 增"前端 v2"段(开发/构建说明);architecture 退役"不前后端分离"约束;roadmap 勾选 M2`openapi/` 已同步入库。
--- ---
+5 -3
View File
@@ -35,7 +35,7 @@
| 里程碑 | 主题 | 一句话 | | 里程碑 | 主题 | 一句话 |
| --- | --- | --- | | --- | --- | --- |
| **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana | | **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana |
| **M2** | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 | | **M2** | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 | | **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上,否则就是在准备推倒的旧数据层上盖新楼、之后回头返工。 排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上,否则就是在准备推倒的旧数据层上盖新楼、之后回头返工。
@@ -101,7 +101,7 @@
--- ---
## M2 — 前端 v2React SPA ## M2 — 前端 v2React SPA✅ 已完成
### 目标 ### 目标
@@ -125,9 +125,11 @@
### 鉴权边界(与 M3 衔接) ### 鉴权边界(与 M3 衔接)
- 现在那个裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)**维持现状到 M3**。 - 现在那个裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)**维持现状到 M3**。
- M2 新增的、浏览器调用的 CRUD 端点,用 session 保护即可,本步不引入 token。 - M2 新增的、浏览器调用的 CRUD 端点,用 session 保护即可,本步不引入 token。
> **M2 已完成**M2-T01 至 M2-T13 全部 done)。Jinja 模板已移除,React SPA 同源托管,多阶段 Docker 构建通过,所有校验闸门绿。
--- ---
## M3 — 开放与移动端(远期试水) ## M3 — 开放与移动端(远期试水)
+88 -3
View File
@@ -11,9 +11,16 @@
"@mantine/core": "^7.17.8", "@mantine/core": "^7.17.8",
"@mantine/hooks": "^7.17.8", "@mantine/hooks": "^7.17.8",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"leaflet.markercluster": "^1.5.3",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-feather": "^2.0.10",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.4" "react-router-dom": "^6.30.4"
}, },
"devDependencies": { "devDependencies": {
@@ -1338,6 +1345,17 @@
"react": "^18.x || ^19.x" "react": "^18.x || ^19.x"
} }
}, },
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@redocly/ajv": { "node_modules/@redocly/ajv": {
"version": "8.11.2", "version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@@ -2053,6 +2071,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2060,6 +2084,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/leaflet.markercluster": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz",
"integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==",
"license": "MIT",
"dependencies": {
"@types/leaflet": "^1.9"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -4951,6 +4993,26 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/leaflet.heat": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
"license": "MIT",
"peerDependencies": {
"leaflet": "^1.3.1"
}
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5136,7 +5198,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -5577,7 +5638,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@@ -5589,7 +5649,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/punycode": { "node_modules/punycode": {
@@ -5627,6 +5686,18 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-feather": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/react-feather/-/react-feather-2.0.10.tgz",
"integrity": "sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"react": ">=16.8.6"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -5634,6 +5705,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-number-format": { "node_modules/react-number-format": {
"version": "5.4.5", "version": "5.4.5",
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz", "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz",
+7
View File
@@ -16,9 +16,16 @@
"@mantine/core": "^7.17.8", "@mantine/core": "^7.17.8",
"@mantine/hooks": "^7.17.8", "@mantine/hooks": "^7.17.8",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"leaflet.markercluster": "^1.5.3",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-feather": "^2.0.10",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.4" "react-router-dom": "^6.30.4"
}, },
"devDependencies": { "devDependencies": {
+73 -16
View File
@@ -13,10 +13,17 @@
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07). * AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
*/ */
import { MantineProvider } from '@mantine/core'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
import { Button, Group } from '@mantine/core' import {
MantineProvider,
Group,
ActionIcon,
Tooltip,
useMantineColorScheme,
useComputedColorScheme,
} from '@mantine/core'
import { List, Settings, Sun, Moon, LogOut } from 'react-feather'
// Mantine requires its CSS to be imported once. // Mantine requires its CSS to be imported once.
import '@mantine/core/styles.css' import '@mantine/core/styles.css'
@@ -26,6 +33,7 @@ import { ProtectedRoute } from './auth/ProtectedRoute'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { HomePage } from './pages/HomePage' import { HomePage } from './pages/HomePage'
import { ConfigPage } from './pages/ConfigPage' import { ConfigPage } from './pages/ConfigPage'
import { RecordsPage } from './pages/RecordsPage'
import { ChangePasswordPage } from './pages/ChangePasswordPage' import { ChangePasswordPage } from './pages/ChangePasswordPage'
import apiClient from './api/client' import apiClient from './api/client'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
@@ -69,9 +77,40 @@ function LogoutButton() {
} }
return ( return (
<Button variant="subtle" size="xs" onClick={handleLogout} data-testid="logout-button"> <Tooltip label="Log out">
Log out <ActionIcon
</Button> variant="default"
size="lg"
onClick={handleLogout}
aria-label="Log out"
data-testid="logout-button"
>
<LogOut size={18} />
</ActionIcon>
</Tooltip>
)
}
// ---------------------------------------------------------------------------
// Dark-mode toggle (sits next to the gear / settings icon)
// ---------------------------------------------------------------------------
function ColorSchemeToggle() {
const { setColorScheme } = useMantineColorScheme()
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
const isDark = computed === 'dark'
return (
<Tooltip label={isDark ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
size="lg"
aria-label="Toggle color scheme"
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
data-testid="color-scheme-toggle"
>
{isDark ? <Sun size={18} /> : <Moon size={18} />}
</ActionIcon>
</Tooltip>
) )
} }
@@ -89,7 +128,7 @@ function AppLayout() {
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '0.5rem 1rem', padding: '0.5rem 1rem',
borderBottom: '1px solid #eee', borderBottom: '1px solid var(--mantine-color-default-border)',
}} }}
> >
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}> <Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
@@ -97,15 +136,32 @@ function AppLayout() {
</Link> </Link>
<Group gap="xs"> <Group gap="xs">
{/* Gear icon nav slot — links to config page (§5#10) */} {/* Records nav link */}
<Link <Tooltip label="Records">
to="/config" <ActionIcon
aria-label="Configuration" component={Link}
style={{ fontSize: '1.25rem', textDecoration: 'none' }} to="/records"
title="Configuration" variant="default"
> size="lg"
aria-label="Records"
</Link> >
<List size={18} />
</ActionIcon>
</Tooltip>
{/* Dark-mode toggle — directly beside the settings gear */}
<ColorSchemeToggle />
{/* Settings — links to config page (§5#10) */}
<Tooltip label="Settings">
<ActionIcon
component={Link}
to="/config"
variant="default"
size="lg"
aria-label="Settings"
>
<Settings size={18} />
</ActionIcon>
</Tooltip>
<LogoutButton /> <LogoutButton />
</Group> </Group>
</nav> </nav>
@@ -124,7 +180,7 @@ function AppLayout() {
export default function App() { export default function App() {
return ( return (
<MantineProvider> <MantineProvider defaultColorScheme="auto">
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<SessionProvider> <SessionProvider>
@@ -152,6 +208,7 @@ export default function App() {
> >
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
<Route path="/config" element={<ConfigPage />} /> <Route path="/config" element={<ConfigPage />} />
<Route path="/records" element={<RecordsPage />} />
</Route> </Route>
</Routes> </Routes>
</SessionProvider> </SessionProvider>
+62
View File
@@ -0,0 +1,62 @@
/**
* csrfMiddleware 401-handling regression tests.
*
* Bug: clicking Logout (or landing on /login) flooded GET /api/session with 401s
* and the page hung instead of returning to the login screen.
*
* Root cause: the middleware redirected on EVERY 401, including the session
* probe's own 401. The redirect invalidated the ['session'] query, which
* refetched GET /api/session, which 401'd, which redirected again → an infinite
* refetch loop. These tests pin the fix: the session probe and the login
* endpoint own their 401s (no redirect); any other endpoint's 401 still
* redirects (session expired mid-use).
*
* We call onResponse() directly (rather than going through apiClient.GET) so the
* test exercises the exact 401 branch without the singleton's relative baseUrl,
* which has no absolute origin to resolve against under jsdom.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Middleware } from 'openapi-fetch'
import { csrfMiddleware, registerLoginRedirect } from './client'
type OnResponse = NonNullable<Middleware['onResponse']>
type OnResponseParams = Parameters<OnResponse>[0]
/** Build the minimal onResponse params for the given schema path + response. */
function params(schemaPath: string, response: Response): OnResponseParams {
return { schemaPath, response, request: new Request('http://test.local' + schemaPath) } as OnResponseParams
}
function response401(): Response {
return new Response(JSON.stringify({ detail: 'unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
const onResponse = csrfMiddleware.onResponse as OnResponse
describe('csrfMiddleware 401 redirect (session-flood regression)', () => {
const redirect = vi.fn()
beforeEach(() => {
redirect.mockReset()
registerLoginRedirect(redirect)
})
it('does NOT redirect when GET /api/session returns 401 (probe owns its 401)', async () => {
await onResponse(params('/api/session', response401()))
expect(redirect).not.toHaveBeenCalled()
})
it('does NOT redirect when POST /api/auth/login returns 401 (bad credentials)', async () => {
await onResponse(params('/api/auth/login', response401()))
expect(redirect).not.toHaveBeenCalled()
})
it('redirects when a normal endpoint returns 401 (session expired mid-use)', async () => {
await onResponse(params('/api/locations', response401()))
expect(redirect).toHaveBeenCalledTimes(1)
})
})
+21 -5
View File
@@ -51,7 +51,21 @@ export function registerLoginRedirect(fn: () => void): void {
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
const LOGIN_PATH = '/api/auth/login' const LOGIN_PATH = '/api/auth/login'
const csrfMiddleware: Middleware = { /**
* Endpoints where a 401 is an EXPECTED, locally-handled outcome and must NOT
* trigger the global login redirect:
* - GET /api/session — the session probe; 401 means "not logged in", handled
* by SessionProvider's queryFn (returns null → unauthenticated state).
* - POST /api/auth/login — bad-credentials check; 401 handled by LoginPage.
*
* Redirecting on these would invalidate the session query, which refetches
* /api/session, which 401s, which redirects again → an infinite loop that
* floods GET /api/session after logout and on the login page.
*/
const SESSION_PATH = '/api/session'
const NO_REDIRECT_ON_401 = new Set<string>([SESSION_PATH, LOGIN_PATH])
export const csrfMiddleware: Middleware = {
async onRequest({ request }) { async onRequest({ request }) {
// Always include cookies (same-origin; explicit for clarity) // Always include cookies (same-origin; explicit for clarity)
// Note: credentials is set at client level; this is belt-and-suspenders doc. // Note: credentials is set at client level; this is belt-and-suspenders doc.
@@ -69,11 +83,13 @@ const csrfMiddleware: Middleware = {
return request return request
}, },
async onResponse({ response }) { async onResponse({ schemaPath, response }) {
if (response.status === 401) { if (response.status === 401) {
// Clear any cached session state by triggering a page navigation. // The session probe and the login endpoint own their 401s (see
// The SessionProvider query will refetch and find no session. // NO_REDIRECT_ON_401). For any OTHER endpoint, a 401 means the session
if (_navigateToLogin) { // expired mid-use → redirect to /login. Crucially, NOT redirecting on the
// session probe breaks the refetch→401→redirect→refetch flood loop.
if (!NO_REDIRECT_ON_401.has(schemaPath) && _navigateToLogin) {
_navigateToLogin() _navigateToLogin()
} }
// Return the original response so callers can handle 401 if needed. // Return the original response so callers can handle 401 if needed.
-365
View File
@@ -21,127 +21,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Login Page */
get: operations["login_page_login_get"];
put?: never;
/** Login Submit */
post: operations["login_submit_login_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/config/change-password": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Change Password Submit */
post: operations["change_password_submit_config_change_password_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Logout */
post: operations["logout_logout_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Home */
get: operations["home__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Admin Redirect */
get: operations["admin_redirect_admin_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Config Page */
get: operations["config_page_config_get"];
put?: never;
/** Config Submit */
post: operations["config_submit_config_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/config/smtp/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Smtp Test Submit */
post: operations["smtp_test_submit_config_smtp_test_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/config": { "/api/config": {
parameters: { parameters: {
query?: never; query?: never;
@@ -544,31 +423,6 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
/** Body_change_password_submit_config_change_password_post */
Body_change_password_submit_config_change_password_post: {
/** Current Password */
current_password: string;
/** New Password */
new_password: string;
/** Confirm Password */
confirm_password: string;
/** Csrf Token */
csrf_token: string;
};
/** Body_login_submit_login_post */
Body_login_submit_login_post: {
/** Username */
username: string;
/** Password */
password: string;
/** Csrf Token */
csrf_token: string;
};
/** Body_logout_logout_post */
Body_logout_logout_post: {
/** Csrf Token */
csrf_token: string;
};
/** ConfigField */ /** ConfigField */
ConfigField: { ConfigField: {
/** Env Name */ /** Env Name */
@@ -831,225 +685,6 @@ export interface operations {
}; };
}; };
}; };
login_page_login_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/html": string;
};
};
};
};
login_submit_login_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/x-www-form-urlencoded": components["schemas"]["Body_login_submit_login_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/html": string;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
change_password_submit_config_change_password_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/x-www-form-urlencoded": components["schemas"]["Body_change_password_submit_config_change_password_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/html": string;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
logout_logout_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/x-www-form-urlencoded": components["schemas"]["Body_logout_logout_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
home__get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/html": string;
};
};
};
};
admin_redirect_admin_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/html": string;
};
};
};
};
config_page_config_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/html": string;
};
};
};
};
config_submit_config_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/html": string;
};
};
};
};
smtp_test_submit_config_smtp_test_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/html": string;
};
};
};
};
get_config_api_config_get: { get_config_api_config_get: {
parameters: { parameters: {
query?: never; query?: never;
+118
View File
@@ -0,0 +1,118 @@
/**
* HeatLayers regression test — post-walkthrough fix.
*
* Bug: the heat layer's `setLatLngs` was called BEFORE the layer was added to the
* map. A leaflet.heat layer that is not on a map has a null `_map`, and
* `setLatLngs -> redraw` dereferences `_map._animating`, throwing
* "Cannot read properties of null (reading '_animating')" and white-screening
* the whole SPA right after login.
*
* This test exercises the REAL HeatLayers code path (not a wholesale RecordsMap
* mock) and asserts the layer is added to the map BEFORE setLatLngs is called.
* Against the old code (setLatLngs first), the ordering assertion fails.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render } from '@testing-library/react'
const { callLog, setLatLngsSpy, mapAddLayerSpy } = vi.hoisted(() => {
const callLog: string[] = []
const setLatLngsSpy = vi.fn((_pts: unknown) => {
callLog.push('setLatLngs')
})
const mapAddLayerSpy = vi.fn((_layer: unknown) => {
callLog.push('addLayer')
})
return { callLog, setLatLngsSpy, mapAddLayerSpy }
})
// Mock leaflet. heatLayer returns a fake layer whose setLatLngs logs call order;
// Icon/DivIcon/marker exist because RecordsMap.tsx runs icon setup at module load.
vi.mock('leaflet', () => {
class FakeIcon {
constructor(_opts: unknown) {}
static Default = { prototype: {}, mergeOptions: vi.fn() }
}
return {
Icon: FakeIcon,
DivIcon: vi.fn(function FakeDivIcon(_opts: unknown) {
return {}
}),
heatLayer: vi.fn(() => ({ setLatLngs: setLatLngsSpy, setOptions: vi.fn(), addTo: vi.fn() })),
markerClusterGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), clearLayers: vi.fn() })),
marker: vi.fn(() => ({ bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() })),
default: {},
}
})
vi.mock('leaflet.heat', () => ({}))
vi.mock('leaflet.markercluster', () => ({}))
vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' }))
vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' }))
vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' }))
vi.mock('leaflet/dist/leaflet.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
// useMap returns a fake map; hasLayer=false so addLayer is exercised.
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TileLayer: () => null,
useMap: () => ({
addLayer: mapAddLayerSpy,
removeLayer: vi.fn(),
hasLayer: () => false,
getSize: () => ({ x: 800, y: 600 }),
latLngToContainerPoint: () => ({ x: 100, y: 100 }),
on: vi.fn(),
off: vi.fn(),
}),
}))
import { HeatLayers } from './RecordsMap'
import type { HeatPoint } from './mapUtils'
const heatPoints: HeatPoint[] = [
[39.9, 116.4, 1],
[39.91, 116.41, 1],
]
describe('HeatLayers (real code path — regression for null _map crash)', () => {
beforeEach(() => {
vi.clearAllMocks()
callLog.length = 0
})
it('adds the heat layer to the map BEFORE calling setLatLngs', () => {
render(
<HeatLayers
locationHeatPoints={heatPoints}
pooHeatPoints={[]}
showLocationHeat={true}
showPooHeat={false}
/>,
)
// Data was applied...
expect(setLatLngsSpy).toHaveBeenCalledWith(heatPoints)
// ...and the layer was added to the map first. The old buggy order
// (setLatLngs before addLayer) makes this fail.
expect(callLog).toEqual(['addLayer', 'setLatLngs'])
expect(callLog.indexOf('addLayer')).toBeLessThan(callLog.indexOf('setLatLngs'))
})
it('does not call setLatLngs while the layer is hidden (off the map)', () => {
render(
<HeatLayers
locationHeatPoints={heatPoints}
pooHeatPoints={heatPoints}
showLocationHeat={false}
showPooHeat={false}
/>,
)
// Hidden layers are never on the map, so setLatLngs must not run on them.
expect(setLatLngsSpy).not.toHaveBeenCalled()
expect(mapAddLayerSpy).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,246 @@
/**
* ScatterLayer unit test — M2-T09 REWORK 1.
*
* This test exercises the REAL ScatterLayer code path (not a wholesale RecordsMap mock).
* It verifies that ScatterLayer uses the imported leaflet namespace (L.markerClusterGroup)
* rather than window.L / globalThis.L, which would silently fail in Vite ESM bundles.
*
* The test:
* - mocks react-leaflet's useMap() to return a fake map object
* - provides a mock markerClusterGroup spy via the leaflet module mock
* - renders ScatterLayer with some points
* - asserts that L.markerClusterGroup was called (i.e. the import path is used)
* - asserts that addLayer was called for each point
* - asserts that clicking a marker invokes onSelectLocation / onSelectPoo
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render } from '@testing-library/react'
import type { ReactNode } from 'react'
// ---------------------------------------------------------------------------
// Use vi.hoisted() to define mocks that are referenced inside vi.mock factories.
// vi.mock() factories are hoisted to the top of the file, so any variables they
// reference must also be hoisted.
// ---------------------------------------------------------------------------
const { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers } =
vi.hoisted(() => {
const clickHandlers: Array<() => void> = []
const fakeAddLayer = vi.fn()
const fakeCluster = {
addLayer: fakeAddLayer,
addTo: vi.fn(),
clearLayers: vi.fn(),
}
const markerClusterGroupSpy = vi.fn(() => fakeCluster)
const fakeMapAddLayer = vi.fn()
return { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers: clickHandlers }
})
// ---------------------------------------------------------------------------
// Mock leaflet BEFORE importing ScatterLayer.
// We use the hoisted spy so vi.mock factory can reference it safely.
// ---------------------------------------------------------------------------
vi.mock('leaflet', () => {
const markerClusterGroupSpy_ = markerClusterGroupSpy
const markerClickHandlers_ = markerClickHandlers
// Icon must be a real constructor (used as `new Icon(...)`)
class FakeIcon {
constructor(_opts: unknown) {}
static Default = { prototype: {}, mergeOptions: vi.fn() }
}
return {
Icon: FakeIcon,
DivIcon: vi.fn(function FakeDivIcon(_opts: unknown) { return {} }),
heatLayer: vi.fn(() => ({ setLatLngs: vi.fn(), addTo: vi.fn() })),
markerClusterGroup: markerClusterGroupSpy_,
marker: vi.fn((_latlng: unknown, _opts: unknown) => {
return {
bindTooltip: vi.fn().mockReturnThis(),
on: vi.fn((event: string, handler: () => void) => {
if (event === 'click') {
markerClickHandlers_.push(handler)
}
return { bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() }
}),
}
}),
// `import * as L from 'leaflet'` in RecordsMap.tsx resolves to this module.
// Vitest's module mock exposes all named exports as the namespace object,
// so markerClusterGroup at the top level IS accessible as L.markerClusterGroup.
default: {
markerClusterGroup: markerClusterGroupSpy_,
},
}
})
vi.mock('leaflet.heat', () => ({}))
vi.mock('leaflet.markercluster', () => ({}))
// Mock image imports
vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' }))
vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' }))
vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' }))
// Mock CSS imports
vi.mock('leaflet/dist/leaflet.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
// ---------------------------------------------------------------------------
// Mock react-leaflet: MapContainer renders children, useMap returns fake map.
// ---------------------------------------------------------------------------
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: ReactNode }) => (
<div data-testid="map-container">{children}</div>
),
TileLayer: () => null,
useMap: () => ({
addLayer: fakeMapAddLayer,
removeLayer: vi.fn(),
hasLayer: vi.fn(() => false),
}),
}))
// ---------------------------------------------------------------------------
// Import ScatterLayer AFTER mocks are set up.
// ---------------------------------------------------------------------------
import { ScatterLayer } from './RecordsMap'
import type { LocationMapPoint, PooMapPoint } from './mapUtils'
import type { LocationRecord, PooRecord } from '../records'
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const locationRecord: LocationRecord = {
person: 'alice',
datetime: '2026-01-15T10:00:00Z',
latitude: 39.9,
longitude: 116.4,
altitude: null,
}
const locationPoints: LocationMapPoint[] = [
{ lat: 39.9, lng: 116.4, record: locationRecord },
]
const pooRecord: PooRecord = {
timestamp: '2026-01-20T09:00:00Z',
status: 'done',
latitude: 39.91,
longitude: 116.41,
}
const pooPoints: PooMapPoint[] = [
{ lat: 39.91, lng: 116.41, record: pooRecord },
]
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ScatterLayer (real code path — not mocked RecordsMap)', () => {
beforeEach(() => {
vi.clearAllMocks()
markerClickHandlers.length = 0
})
it('calls L.markerClusterGroup (imported namespace) when showScatter=true', () => {
render(
<ScatterLayer
locationScatterPoints={locationPoints}
pooScatterPoints={[]}
showScatter={true}
onSelectLocation={vi.fn()}
onSelectPoo={vi.fn()}
/>,
)
// KEY assertion: markerClusterGroup was called via the IMPORTED namespace.
// With the old window.L / globalThis.L approach, this spy would never be
// invoked because window.L is undefined in Vite ESM bundles.
expect(markerClusterGroupSpy).toHaveBeenCalledOnce()
expect(markerClusterGroupSpy).toHaveBeenCalledWith({
maxClusterRadius: 50,
showCoverageOnHover: false,
})
})
it('calls cluster group addLayer for each location and poo scatter point', () => {
render(
<ScatterLayer
locationScatterPoints={locationPoints}
pooScatterPoints={pooPoints}
showScatter={true}
onSelectLocation={vi.fn()}
onSelectPoo={vi.fn()}
/>,
)
// One addLayer call per point (1 location + 1 poo = 2).
expect(fakeAddLayer).toHaveBeenCalledTimes(2)
// The cluster group itself must be added to the map.
const fakeCluster = markerClusterGroupSpy.mock.results[0]?.value
expect(fakeMapAddLayer).toHaveBeenCalledWith(fakeCluster)
})
it('does NOT create cluster group when showScatter=false', () => {
render(
<ScatterLayer
locationScatterPoints={locationPoints}
pooScatterPoints={pooPoints}
showScatter={false}
onSelectLocation={vi.fn()}
onSelectPoo={vi.fn()}
/>,
)
expect(markerClusterGroupSpy).not.toHaveBeenCalled()
expect(fakeAddLayer).not.toHaveBeenCalled()
})
it('invokes onSelectLocation when a location marker is clicked', () => {
const onSelectLocation = vi.fn()
render(
<ScatterLayer
locationScatterPoints={locationPoints}
pooScatterPoints={[]}
showScatter={true}
onSelectLocation={onSelectLocation}
onSelectPoo={vi.fn()}
/>,
)
// At least one marker click handler should have been registered.
expect(markerClickHandlers.length).toBeGreaterThan(0)
// Simulate click on the first (location) marker.
markerClickHandlers[0]()
expect(onSelectLocation).toHaveBeenCalledOnce()
expect(onSelectLocation).toHaveBeenCalledWith(locationRecord)
})
it('invokes onSelectPoo when a poo marker is clicked', () => {
const onSelectPoo = vi.fn()
render(
<ScatterLayer
locationScatterPoints={[]}
pooScatterPoints={pooPoints}
showScatter={true}
onSelectLocation={vi.fn()}
onSelectPoo={onSelectPoo}
/>,
)
expect(markerClickHandlers.length).toBeGreaterThan(0)
markerClickHandlers[0]()
expect(onSelectPoo).toHaveBeenCalledOnce()
expect(onSelectPoo).toHaveBeenCalledWith(pooRecord)
})
})
+345
View File
@@ -0,0 +1,345 @@
/**
* RecordsMap — self-contained Leaflet map component (M2-T09).
*
* THIS IS THE ONLY MODULE IN THE APP THAT IMPORTS LEAFLET / REACT-LEAFLET.
* All data fetching and state lives outside; this component receives typed props.
*/
import { useEffect, useRef, useCallback } from 'react'
import { MapContainer, TileLayer, useMap } from 'react-leaflet'
import * as L from 'leaflet'
import {
Icon,
DivIcon,
marker as leafletMarker,
heatLayer as leafletHeatLayer,
type HeatLayer,
} from 'leaflet'
// Leaflet CSS — must be imported once; this component is the single place.
import 'leaflet/dist/leaflet.css'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
// Side-effect imports (augment L with heatLayer and markerClusterGroup)
import 'leaflet.heat'
import 'leaflet.markercluster'
import { peakGridCount } from './mapUtils'
import type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils'
import type { LocationRecord, PooRecord } from '../records'
// Fix default Leaflet marker icon paths broken by Vite asset handling.
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
import markerIcon from 'leaflet/dist/images/marker-icon.png'
import markerShadow from 'leaflet/dist/images/marker-shadow.png'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (Icon.Default.prototype as any)._getIconUrl
Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
})
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export interface RecordsMapProps {
locationHeatPoints: HeatPoint[]
pooHeatPoints: HeatPoint[]
locationScatterPoints: LocationMapPoint[]
pooScatterPoints: PooMapPoint[]
showLocationHeat: boolean
showPooHeat: boolean
showScatter: boolean
onSelectLocation?: (record: LocationRecord) => void
onSelectPoo?: (record: PooRecord) => void
/** Map container height (CSS value). Default: '100%'. */
height?: string
/** Use dark base tiles to match the app's dark color scheme. */
dark?: boolean
}
// OSM (light) and CARTO dark_all (dark) raster tiles — both zero-key.
const LIGHT_TILES = {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}
const DARK_TILES = {
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
}
// ---------------------------------------------------------------------------
// Inner child: Heat layers (uses useMap hook — must be inside MapContainer)
// ---------------------------------------------------------------------------
interface HeatLayerChildProps {
locationHeatPoints: HeatPoint[]
pooHeatPoints: HeatPoint[]
showLocationHeat: boolean
showPooHeat: boolean
}
// Heat layer geometry. maxZoom:0 makes leaflet.heat's zoom intensity factor f=1
// at every zoom, so accumulated per-cell intensity equals the raw point count —
// which lets us normalize with a pixel-grid count below.
const LOC_HEAT = { radius: 20, blur: 15 }
const POO_HEAT = { radius: 25, blur: 18 }
/**
* leaflet.heat `max` (normalization denominator) for the CURRENT viewport:
* project the points that are visible (within the map size + a radius margin) to
* container pixels, then count the densest pixel cell using leaflet.heat's own
* grid (cell = (radius+blur)/2). The densest visible cluster maps to the hot
* color; recomputing on every zoom/pan keeps it normalized to what's on screen.
*/
function viewportHeatMax(map: L.Map, points: HeatPoint[], radius: number, blur: number): number {
if (points.length === 0) return 1
const cell = (radius + blur) / 2
const size = map.getSize()
const margin = radius + blur
const coords: Array<[number, number]> = []
for (let i = 0; i < points.length; i++) {
const p = map.latLngToContainerPoint([points[i][0], points[i][1]])
if (p.x < -margin || p.y < -margin || p.x > size.x + margin || p.y > size.y + margin) continue
coords.push([p.x, p.y])
}
return peakGridCount(coords, cell)
}
export function HeatLayers({
locationHeatPoints,
pooHeatPoints,
showLocationHeat,
showPooHeat,
}: HeatLayerChildProps) {
const map = useMap()
const locationLayerRef = useRef<HeatLayer | null>(null)
const pooLayerRef = useRef<HeatLayer | null>(null)
// Latest data/visibility in refs so the once-registered map move/zoom handler
// re-normalizes against the current points without re-subscribing.
const locPointsRef = useRef(locationHeatPoints)
const pooPointsRef = useRef(pooHeatPoints)
const showLocRef = useRef(showLocationHeat)
const showPooRef = useRef(showPooHeat)
useEffect(() => {
locPointsRef.current = locationHeatPoints
pooPointsRef.current = pooHeatPoints
showLocRef.current = showLocationHeat
showPooRef.current = showPooHeat
})
// Location heat layer
useEffect(() => {
if (!locationLayerRef.current) {
locationLayerRef.current = leafletHeatLayer([], {
...LOC_HEAT,
maxZoom: 0,
gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' },
})
}
const layer = locationLayerRef.current
if (showLocationHeat) {
// Add the layer to the map BEFORE setLatLngs. A heat layer that is not on
// a map has a null `_map`, and `setLatLngs -> redraw` dereferences
// `_map._animating`, which throws and white-screens the SPA.
if (!map.hasLayer(layer)) map.addLayer(layer)
layer.setLatLngs(locationHeatPoints)
layer.setOptions({ max: viewportHeatMax(map, locationHeatPoints, LOC_HEAT.radius, LOC_HEAT.blur) })
} else {
if (map.hasLayer(layer)) map.removeLayer(layer)
}
return () => {
if (map.hasLayer(layer)) map.removeLayer(layer)
}
}, [map, locationHeatPoints, showLocationHeat])
// Poo heat layer
useEffect(() => {
if (!pooLayerRef.current) {
pooLayerRef.current = leafletHeatLayer([], {
...POO_HEAT,
maxZoom: 0,
// High-frequency poo spots reach red (per request); mid tones stay
// yellow/orange to distinguish from the location layer.
gradient: { 0.4: 'yellow', 0.7: 'orange', 1: 'red' },
})
}
const layer = pooLayerRef.current
if (showPooHeat) {
// Add to the map before setLatLngs (see the location heat layer above).
if (!map.hasLayer(layer)) map.addLayer(layer)
layer.setLatLngs(pooHeatPoints)
layer.setOptions({ max: viewportHeatMax(map, pooHeatPoints, POO_HEAT.radius, POO_HEAT.blur) })
} else {
if (map.hasLayer(layer)) map.removeLayer(layer)
}
return () => {
if (map.hasLayer(layer)) map.removeLayer(layer)
}
}, [map, pooHeatPoints, showPooHeat])
// Re-normalize each visible layer to the viewport peak on pan/zoom.
useEffect(() => {
const recompute = () => {
const loc = locationLayerRef.current
if (loc && showLocRef.current && map.hasLayer(loc)) {
loc.setOptions({ max: viewportHeatMax(map, locPointsRef.current, LOC_HEAT.radius, LOC_HEAT.blur) })
}
const poo = pooLayerRef.current
if (poo && showPooRef.current && map.hasLayer(poo)) {
poo.setOptions({ max: viewportHeatMax(map, pooPointsRef.current, POO_HEAT.radius, POO_HEAT.blur) })
}
}
map.on('moveend', recompute)
map.on('zoomend', recompute)
return () => {
map.off('moveend', recompute)
map.off('zoomend', recompute)
}
}, [map])
return null
}
// ---------------------------------------------------------------------------
// Inner child: Scatter / cluster layer
// ---------------------------------------------------------------------------
interface ScatterLayerChildProps {
locationScatterPoints: LocationMapPoint[]
pooScatterPoints: PooMapPoint[]
showScatter: boolean
onSelectLocation?: (record: LocationRecord) => void
onSelectPoo?: (record: PooRecord) => void
}
const locationIcon = new Icon({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
})
const pooIcon = new DivIcon({
html: '<div style="font-size:20px;line-height:1;">💩</div>',
className: '',
iconSize: [24, 24],
iconAnchor: [12, 12],
})
export function ScatterLayer({
locationScatterPoints,
pooScatterPoints,
showScatter,
onSelectLocation,
onSelectPoo,
}: ScatterLayerChildProps) {
const map = useMap()
const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(null)
const rebuild = useCallback(() => {
if (clusterGroupRef.current) {
map.removeLayer(clusterGroupRef.current)
clusterGroupRef.current = null
}
if (!showScatter) return
// markerClusterGroup is augmented onto the imported L namespace by the
// leaflet.markercluster side-effect import above. Using the imported
// namespace (not window.L) is what works in Vite ESM bundles.
const group = L.markerClusterGroup({ maxClusterRadius: 50, showCoverageOnHover: false })
for (const pt of locationScatterPoints) {
const m = leafletMarker([pt.lat, pt.lng], { icon: locationIcon })
m.bindTooltip(`${pt.record.person}<br/>${pt.record.datetime}`, { sticky: true })
if (onSelectLocation) m.on('click', () => onSelectLocation(pt.record))
group.addLayer(m)
}
for (const pt of pooScatterPoints) {
const m = leafletMarker([pt.lat, pt.lng], { icon: pooIcon })
m.bindTooltip(`${pt.record.timestamp}<br/>${pt.record.status}`, { sticky: true })
if (onSelectPoo) m.on('click', () => onSelectPoo(pt.record))
group.addLayer(m)
}
map.addLayer(group)
clusterGroupRef.current = group
}, [map, locationScatterPoints, pooScatterPoints, showScatter, onSelectLocation, onSelectPoo])
useEffect(() => {
rebuild()
return () => {
if (clusterGroupRef.current) {
map.removeLayer(clusterGroupRef.current)
clusterGroupRef.current = null
}
}
}, [rebuild, map])
return null
}
// ---------------------------------------------------------------------------
// Public component
// ---------------------------------------------------------------------------
/** Default map center: Beijing area. */
const DEFAULT_CENTER: [number, number] = [39.9, 116.4]
const DEFAULT_ZOOM = 11
export function RecordsMap({
locationHeatPoints,
pooHeatPoints,
locationScatterPoints,
pooScatterPoints,
showLocationHeat,
showPooHeat,
showScatter,
onSelectLocation,
onSelectPoo,
height = '100%',
dark = false,
}: RecordsMapProps) {
const tiles = dark ? DARK_TILES : LIGHT_TILES
return (
<MapContainer
center={DEFAULT_CENTER}
zoom={DEFAULT_ZOOM}
style={{ height, width: '100%', background: dark ? '#1a1b1e' : undefined }}
data-testid="records-map"
>
{/* key forces a clean tile-layer swap when the color scheme changes */}
<TileLayer key={tiles.url} attribution={tiles.attribution} url={tiles.url} />
<HeatLayers
locationHeatPoints={locationHeatPoints}
pooHeatPoints={pooHeatPoints}
showLocationHeat={showLocationHeat}
showPooHeat={showPooHeat}
/>
<ScatterLayer
locationScatterPoints={locationScatterPoints}
pooScatterPoints={pooScatterPoints}
showScatter={showScatter}
onSelectLocation={onSelectLocation}
onSelectPoo={onSelectPoo}
/>
</MapContainer>
)
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Tests for peakGridCount — the pure pixel-grid peak counter used to normalize
* each heat layer to the densest cell visible in the current viewport.
*/
import { describe, it, expect } from 'vitest'
import { peakGridCount } from './mapUtils'
describe('peakGridCount', () => {
it('returns 1 for empty input (no divide-by-zero)', () => {
expect(peakGridCount([], 10)).toBe(1)
})
it('counts coords sharing a grid cell and returns the peak', () => {
const coords: Array<[number, number]> = [
[0, 0],
[3, 4], // same 10px cell as [0,0]
[9, 9], // same 10px cell
[100, 100], // different cell
]
expect(peakGridCount(coords, 10)).toBe(3)
})
it('separates coords into different cells by cellSize', () => {
const coords: Array<[number, number]> = [
[0, 0],
[10, 0], // next cell over at cellSize 10
[20, 0], // next again
]
expect(peakGridCount(coords, 10)).toBe(1)
})
it('a denser cluster yields a larger peak (drives per-layer normalization)', () => {
const dense: Array<[number, number]> = Array.from({ length: 12 }, () => [5, 5] as [number, number])
const sparse: Array<[number, number]> = [
[5, 5],
[5, 5],
]
expect(peakGridCount(dense, 10)).toBe(12)
expect(peakGridCount(sparse, 10)).toBe(2)
})
})
+21
View File
@@ -0,0 +1,21 @@
/**
* Public surface of the map module (M2-T09).
* Only RecordsMap.tsx imports leaflet — external code should not.
*/
export { RecordsMap } from './RecordsMap'
export type { RecordsMapProps } from './RecordsMap'
export {
locationsToHeatPoints,
pooToHeatPoints,
locationsToMapPoints,
pooToMapPoints,
filterPooByTimeWindow,
daysAgoISO,
nowISO,
computeCenter,
TIME_PRESETS,
presetRange,
shiftRange,
} from './mapUtils'
export type { HeatPoint, LocationMapPoint, PooMapPoint, TimePreset } from './mapUtils'
+40
View File
@@ -0,0 +1,40 @@
/**
* Ambient type declarations for leaflet.heat (no @types package available).
*
* This file must be a MODULE (has a top-level export) so that `declare module 'leaflet'`
* is treated as an AUGMENTATION of the existing leaflet types, not a replacement.
* Without the export, the `declare module 'leaflet'` block would shadow all of @types/leaflet.
*/
// This empty export makes the file a module, enabling proper augmentation semantics.
export {}
// Augment the 'leaflet' module to add heatLayer and HeatLayer types.
declare module 'leaflet' {
type HeatLatLngTuple = [number, number] | [number, number, number]
interface HeatLayerOptions {
minOpacity?: number
maxZoom?: number
max?: number
radius?: number
blur?: number
gradient?: Record<number, string>
}
class HeatLayer extends Layer {
setLatLngs(latlngs: HeatLatLngTuple[]): this
addLatLng(latlng: HeatLatLngTuple): this
setOptions(options: HeatLayerOptions): this
redraw(): this
}
function heatLayer(latlngs: HeatLatLngTuple[], options?: HeatLayerOptions): HeatLayer
}
// Declare leaflet.heat as a side-effect-only module.
declare module 'leaflet.heat' {
// Side-effect: augments the Leaflet global with the heatLayer plugin.
const _: undefined
export default _
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Unit tests for mapUtils.ts — pure logic, no leaflet, runs in jsdom.
*/
import { describe, it, expect } from 'vitest'
import {
locationsToHeatPoints,
pooToHeatPoints,
locationsToMapPoints,
pooToMapPoints,
filterPooByTimeWindow,
computeCenter,
daysAgoISO,
} from './mapUtils'
import type { LocationRecord, PooRecord } from '../records'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const loc1: LocationRecord = {
person: 'alice',
datetime: '2026-01-15T10:00:00Z',
latitude: 39.9,
longitude: 116.4,
altitude: 50,
}
const loc2: LocationRecord = {
person: 'alice',
datetime: '2026-01-20T12:00:00Z',
latitude: 39.95,
longitude: 116.45,
altitude: null,
}
const poo1: PooRecord = {
timestamp: '2026-01-10T08:00:00Z',
status: 'done',
latitude: 39.91,
longitude: 116.41,
}
const poo2: PooRecord = {
timestamp: '2026-01-20T09:00:00Z',
status: 'done',
latitude: 39.92,
longitude: 116.42,
}
const poo3: PooRecord = {
timestamp: '2026-02-01T09:00:00Z',
status: 'done',
latitude: 39.93,
longitude: 116.43,
}
// ---------------------------------------------------------------------------
// locationsToHeatPoints
// ---------------------------------------------------------------------------
describe('locationsToHeatPoints', () => {
it('converts records to [lat, lng, 1] tuples', () => {
const pts = locationsToHeatPoints([loc1, loc2])
expect(pts).toHaveLength(2)
expect(pts[0]).toEqual([39.9, 116.4, 1])
expect(pts[1]).toEqual([39.95, 116.45, 1])
})
it('returns empty array for empty input', () => {
expect(locationsToHeatPoints([])).toEqual([])
})
})
// ---------------------------------------------------------------------------
// pooToHeatPoints
// ---------------------------------------------------------------------------
describe('pooToHeatPoints', () => {
it('converts poo records to heat points', () => {
const pts = pooToHeatPoints([poo1])
expect(pts).toHaveLength(1)
expect(pts[0]).toEqual([39.91, 116.41, 1])
})
})
// ---------------------------------------------------------------------------
// locationsToMapPoints
// ---------------------------------------------------------------------------
describe('locationsToMapPoints', () => {
it('attaches original record to each point', () => {
const pts = locationsToMapPoints([loc1])
expect(pts).toHaveLength(1)
expect(pts[0].lat).toBe(39.9)
expect(pts[0].lng).toBe(116.4)
expect(pts[0].record).toBe(loc1)
})
})
// ---------------------------------------------------------------------------
// pooToMapPoints
// ---------------------------------------------------------------------------
describe('pooToMapPoints', () => {
it('attaches original poo record to each point', () => {
const pts = pooToMapPoints([poo1])
expect(pts[0].record).toBe(poo1)
})
})
// ---------------------------------------------------------------------------
// filterPooByTimeWindow — client-side time filter
// ---------------------------------------------------------------------------
describe('filterPooByTimeWindow', () => {
const records = [poo1, poo2, poo3]
// timestamps: 2026-01-10, 2026-01-20, 2026-02-01
it('returns all records when start and end are both null', () => {
expect(filterPooByTimeWindow(records, null, null)).toHaveLength(3)
})
it('filters by start (inclusive)', () => {
const result = filterPooByTimeWindow(records, '2026-01-15T00:00:00Z', null)
expect(result).toHaveLength(2)
expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z')
expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z')
})
it('filters by end (inclusive)', () => {
const result = filterPooByTimeWindow(records, null, '2026-01-20T09:00:00Z')
expect(result).toHaveLength(2)
expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z')
expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z')
})
it('filters by both start and end', () => {
const result = filterPooByTimeWindow(
records,
'2026-01-15T00:00:00Z',
'2026-01-25T00:00:00Z',
)
expect(result).toHaveLength(1)
expect(result[0].timestamp).toBe('2026-01-20T09:00:00Z')
})
it('returns empty when no records match', () => {
const result = filterPooByTimeWindow(records, '2027-01-01T00:00:00Z', null)
expect(result).toHaveLength(0)
})
it('includes records exactly at start boundary', () => {
const result = filterPooByTimeWindow(records, '2026-01-10T08:00:00Z', null)
expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z')
})
it('includes records exactly at end boundary', () => {
const result = filterPooByTimeWindow(records, null, '2026-02-01T09:00:00Z')
expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z')
})
})
// ---------------------------------------------------------------------------
// computeCenter
// ---------------------------------------------------------------------------
describe('computeCenter', () => {
it('returns null for empty array', () => {
expect(computeCenter([])).toBeNull()
})
it('returns the point for a single-element array', () => {
const result = computeCenter([{ lat: 10, lng: 20 }])
expect(result).toEqual([10, 20])
})
it('returns the average of multiple points', () => {
const result = computeCenter([
{ lat: 0, lng: 0 },
{ lat: 4, lng: 6 },
])
expect(result).toEqual([2, 3])
})
})
// ---------------------------------------------------------------------------
// daysAgoISO
// ---------------------------------------------------------------------------
describe('daysAgoISO', () => {
it('returns a valid ISO string in the past', () => {
const result = daysAgoISO(7)
expect(typeof result).toBe('string')
const d = new Date(result)
expect(isNaN(d.getTime())).toBe(false)
expect(d.getTime()).toBeLessThan(Date.now())
})
})
+184
View File
@@ -0,0 +1,184 @@
/**
* Pure data-transform utilities for the map view (M2-T09).
* No leaflet imports — these functions are unit-testable in jsdom.
*/
import type { LocationRecord, PooRecord } from '../records'
/** A heat point for L.heatLayer: [lat, lng, intensity]. */
export type HeatPoint = [number, number, number]
/** Map point with attached source record for click-to-edit. */
export interface LocationMapPoint {
lat: number
lng: number
record: LocationRecord
}
export interface PooMapPoint {
lat: number
lng: number
record: PooRecord
}
// ---------------------------------------------------------------------------
// Transforms
// ---------------------------------------------------------------------------
/**
* Convert location records to heat points.
* All points get intensity=1; callers can adjust if needed.
*/
export function locationsToHeatPoints(records: LocationRecord[]): HeatPoint[] {
return records.map((r) => [r.latitude, r.longitude, 1])
}
/**
* Convert poo records to heat points.
*/
export function pooToHeatPoints(records: PooRecord[]): HeatPoint[] {
return records.map((r) => [r.latitude, r.longitude, 1])
}
/**
* Peak number of 2D coordinates that fall into the same `cellSize`-sized grid
* cell. Pure + leaflet-free so it is unit-testable.
*
* Used by the map heat normalization: project the VISIBLE points to screen
* pixels (in the map component), then this returns the densest pixel cell's
* count, which becomes leaflet.heat's `max`. With maxZoom:0 (intensity factor
* f=1) the accumulated per-cell value equals this count, so the densest visible
* cluster maps to the hot color — recomputed on every zoom/pan so it always
* normalizes within the current viewport. Returns at least 1.
*/
export function peakGridCount(coords: Array<[number, number]>, cellSize: number): number {
if (coords.length === 0) return 1
const g = Math.max(1, cellSize)
const counts = new Map<string, number>()
let peak = 1
for (const [x, y] of coords) {
const key = `${Math.floor(x / g)}:${Math.floor(y / g)}`
const next = (counts.get(key) ?? 0) + 1
counts.set(key, next)
if (next > peak) peak = next
}
return peak
}
/**
* Convert location records to map points (for scatter layer).
*/
export function locationsToMapPoints(records: LocationRecord[]): LocationMapPoint[] {
return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r }))
}
/**
* Convert poo records to map points (for scatter layer).
*/
export function pooToMapPoints(records: PooRecord[]): PooMapPoint[] {
return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r }))
}
// ---------------------------------------------------------------------------
// Client-side time-window filter (for poo records — the endpoint has no server filter)
// ---------------------------------------------------------------------------
/**
* Filter poo records to those whose timestamp falls within [start, end] (inclusive).
* start and end are ISO8601 strings (e.g. "2026-01-01T00:00:00Z").
* If start or end is null, that bound is open (no filtering on that side).
*/
export function filterPooByTimeWindow(
records: PooRecord[],
start: string | null,
end: string | null,
): PooRecord[] {
if (!start && !end) return records
return records.filter((r) => {
const ts = r.timestamp
if (start && ts < start) return false
if (end && ts > end) return false
return true
})
}
// ---------------------------------------------------------------------------
// Default time window helpers
// ---------------------------------------------------------------------------
/** Returns ISO8601 string for N days ago from now (UTC). */
export function daysAgoISO(days: number): string {
const d = new Date()
d.setUTCDate(d.getUTCDate() - days)
return d.toISOString()
}
/** Returns ISO8601 string for now (UTC). */
export function nowISO(): string {
return new Date().toISOString()
}
/** Compute a bounding center from an array of lat/lng points. Returns null if empty. */
export function computeCenter(
points: Array<{ lat: number; lng: number }>,
): [number, number] | null {
if (points.length === 0) return null
const sumLat = points.reduce((s, p) => s + p.lat, 0)
const sumLng = points.reduce((s, p) => s + p.lng, 0)
return [sumLat / points.length, sumLng / points.length]
}
// ---------------------------------------------------------------------------
// Quick time-range presets + window shifting (Grafana-style)
// ---------------------------------------------------------------------------
const HOUR_MS = 3_600_000
const DAY_MS = 24 * HOUR_MS
/** A quick-range preset: a label + a span in milliseconds (month/year approximated). */
export interface TimePreset {
value: string
label: string
spanMs: number
}
export const TIME_PRESETS: TimePreset[] = [
{ value: '24h', label: 'Past 24 hours', spanMs: 24 * HOUR_MS },
{ value: '1w', label: 'Past 1 week', spanMs: 7 * DAY_MS },
{ value: '2w', label: 'Past 2 weeks', spanMs: 14 * DAY_MS },
{ value: '1mo', label: 'Past 1 month', spanMs: 30 * DAY_MS },
{ value: '6mo', label: 'Past 6 months', spanMs: 182 * DAY_MS },
{ value: '1y', label: 'Past 1 year', spanMs: 365 * DAY_MS },
{ value: '5y', label: 'Past 5 years', spanMs: 5 * 365 * DAY_MS },
]
/** ISO8601 with second precision, no milliseconds: "YYYY-MM-DDTHH:MM:SSZ". */
function isoSeconds(d: Date): string {
return d.toISOString().slice(0, 19) + 'Z'
}
/**
* Compute a [start, end] window of width `spanMs` ending at `now`.
* Used when the user picks a quick-range preset.
*/
export function presetRange(spanMs: number, now: Date = new Date()): { start: string; end: string } {
return { start: isoSeconds(new Date(now.getTime() - spanMs)), end: isoSeconds(now) }
}
/**
* Shift a [start, end] window by its OWN span. direction = -1 moves earlier
* (back in time), +1 moves later. The window width is preserved.
*/
export function shiftRange(
startISO: string,
endISO: string,
direction: -1 | 1,
): { start: string; end: string } {
const startMs = Date.parse(startISO)
const endMs = Date.parse(endISO)
const span = endMs - startMs
return {
start: isoSeconds(new Date(startMs + direction * span)),
end: isoSeconds(new Date(endMs + direction * span)),
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Tests for the quick-range preset + window-shift helpers (Grafana-style).
*/
import { describe, it, expect } from 'vitest'
import { TIME_PRESETS, presetRange, shiftRange } from './mapUtils'
describe('TIME_PRESETS', () => {
it('exposes the 7 expected quick ranges in order', () => {
expect(TIME_PRESETS.map((p) => p.value)).toEqual([
'24h',
'1w',
'2w',
'1mo',
'6mo',
'1y',
'5y',
])
})
})
describe('presetRange', () => {
const now = new Date('2026-06-13T12:00:00Z')
it('ends at now and spans the given duration (24h)', () => {
const { start, end } = presetRange(24 * 3_600_000, now)
expect(end).toBe('2026-06-13T12:00:00Z')
expect(start).toBe('2026-06-12T12:00:00Z')
})
it('spans a week', () => {
const { start, end } = presetRange(7 * 24 * 3_600_000, now)
expect(end).toBe('2026-06-13T12:00:00Z')
expect(start).toBe('2026-06-06T12:00:00Z')
})
it('emits second-precision ISO with no milliseconds', () => {
const { start, end } = presetRange(3_600_000, now)
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
expect(end).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
})
})
describe('shiftRange', () => {
it('moves a 24h window back by 24h when direction = -1', () => {
const { start, end } = shiftRange('2026-06-12T12:00:00Z', '2026-06-13T12:00:00Z', -1)
expect(start).toBe('2026-06-11T12:00:00Z')
expect(end).toBe('2026-06-12T12:00:00Z')
})
it('moves a 24h window forward by 24h when direction = +1', () => {
const { start, end } = shiftRange('2026-06-12T12:00:00Z', '2026-06-13T12:00:00Z', 1)
expect(start).toBe('2026-06-13T12:00:00Z')
expect(end).toBe('2026-06-14T12:00:00Z')
})
it('shifts by the window OWN span (a 1-week window moves a week)', () => {
const { start, end } = shiftRange('2026-06-06T12:00:00Z', '2026-06-13T12:00:00Z', -1)
expect(start).toBe('2026-05-30T12:00:00Z')
expect(end).toBe('2026-06-06T12:00:00Z')
})
it('is reversible: shift back then forward returns to the original window', () => {
const orig = { start: '2026-06-06T12:00:00Z', end: '2026-06-13T12:00:00Z' }
const back = shiftRange(orig.start, orig.end, -1)
const fwd = shiftRange(back.start, back.end, 1)
expect(fwd).toEqual(orig)
})
})
+274
View File
@@ -0,0 +1,274 @@
/**
* HomePage tests — M2-T09.
*
* Leaflet is mocked so jsdom doesn't choke on DOM APIs it doesn't support.
* We verify:
* 1. Controls render (time range inputs, layer toggles, apply button).
* 2. Point-select: when onSelectLocation is called, EditLocationModal opens.
* 3. Point-select: when onSelectPoo is called, EditPooModal opens.
* 4. The map component is rendered (mocked).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MantineProvider } from '@mantine/core'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import type { ReactNode } from 'react'
// ---------------------------------------------------------------------------
// Mock leaflet / react-leaflet before any component imports them.
// ---------------------------------------------------------------------------
vi.mock('leaflet', () => ({
default: {},
Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } },
DivIcon: vi.fn(() => ({})),
heatLayer: vi.fn(() => ({ setLatLngs: vi.fn(), addTo: vi.fn() })),
markerClusterGroup: vi.fn(() => ({ addLayer: vi.fn(), clearLayers: vi.fn() })),
marker: vi.fn(() => ({
bindTooltip: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
})),
tileLayer: vi.fn(),
map: vi.fn(),
}))
vi.mock('leaflet.heat', () => ({}))
vi.mock('leaflet.markercluster', () => ({}))
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: ReactNode }) => (
<div data-testid="records-map">{children}</div>
),
TileLayer: () => null,
useMap: () => ({
addLayer: vi.fn(),
removeLayer: vi.fn(),
hasLayer: vi.fn(() => false),
}),
}))
// Mock leaflet image imports
vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' }))
vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' }))
vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' }))
// Mock leaflet CSS
vi.mock('leaflet/dist/leaflet.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
// ---------------------------------------------------------------------------
// Mock RecordsMap to capture onSelectLocation / onSelectPoo callbacks
// ---------------------------------------------------------------------------
import type { RecordsMapProps } from '../map/RecordsMap'
let capturedOnSelectLocation: RecordsMapProps['onSelectLocation'] | undefined
let capturedOnSelectPoo: RecordsMapProps['onSelectPoo'] | undefined
vi.mock('../map/RecordsMap', () => ({
RecordsMap: (props: RecordsMapProps) => {
capturedOnSelectLocation = props.onSelectLocation
capturedOnSelectPoo = props.onSelectPoo
return <div data-testid="records-map-mock" />
},
}))
// ---------------------------------------------------------------------------
// Mock apiClient — return minimal data so queries resolve
// ---------------------------------------------------------------------------
vi.mock('../api/client', () => ({
default: {
GET: vi.fn(async (path: string) => {
if (path === '/api/locations') {
return {
data: {
items: [
{
person: 'alice',
datetime: '2026-01-15T10:00:00Z',
latitude: 39.9,
longitude: 116.4,
altitude: null,
},
],
limit: 5000,
offset: 0,
},
}
}
if (path === '/api/poo') {
return {
data: {
items: [
{
timestamp: '2026-01-20T09:00:00Z',
status: 'done',
latitude: 39.91,
longitude: 116.41,
},
],
limit: 1000,
offset: 0,
},
}
}
return { data: null }
}),
},
}))
// ---------------------------------------------------------------------------
// Now import components under test (after mocks are registered)
// ---------------------------------------------------------------------------
import { HomePage } from './HomePage'
// ---------------------------------------------------------------------------
// Test wrapper
// ---------------------------------------------------------------------------
function makeQC() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
function Wrapper({ qc, children }: { qc: QueryClient; children: ReactNode }) {
return (
<MantineProvider>
<QueryClientProvider client={qc}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
</MantineProvider>
)
}
// Helper: render HomePage and wait for queries to resolve
async function renderHomePage() {
const qc = makeQC()
const utils = render(
<Wrapper qc={qc}>
<HomePage />
</Wrapper>,
)
// Wait for the map mock to appear (data loaded)
await waitFor(() => screen.getByTestId('records-map-mock'))
return utils
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('HomePage', () => {
beforeEach(() => {
capturedOnSelectLocation = undefined
capturedOnSelectPoo = undefined
})
it('renders time-range controls', async () => {
await renderHomePage()
expect(screen.getByTestId('time-start-input')).toBeTruthy()
expect(screen.getByTestId('time-end-input')).toBeTruthy()
expect(screen.getByTestId('apply-window-button')).toBeTruthy()
})
it('renders layer toggle switches', async () => {
await renderHomePage()
expect(screen.getByTestId('toggle-location-heat')).toBeTruthy()
expect(screen.getByTestId('toggle-poo-heat')).toBeTruthy()
expect(screen.getByTestId('toggle-scatter')).toBeTruthy()
})
it('renders the RecordsMap component', async () => {
await renderHomePage()
expect(screen.getByTestId('records-map-mock')).toBeTruthy()
})
it('opens EditLocationModal when onSelectLocation is called with a location record', async () => {
await renderHomePage()
// Simulate clicking a location scatter point
const record = {
person: 'alice',
datetime: '2026-01-15T10:00:00Z',
latitude: 39.9,
longitude: 116.4,
altitude: null,
}
expect(capturedOnSelectLocation).toBeDefined()
capturedOnSelectLocation!(record)
// EditLocationModal should appear
await waitFor(() => screen.getByTestId('edit-location-modal'))
expect(screen.getByTestId('edit-location-modal')).toBeTruthy()
})
it('opens EditPooModal when onSelectPoo is called with a poo record', async () => {
await renderHomePage()
const record = {
timestamp: '2026-01-20T09:00:00Z',
status: 'done',
latitude: 39.91,
longitude: 116.41,
}
expect(capturedOnSelectPoo).toBeDefined()
capturedOnSelectPoo!(record)
await waitFor(() => screen.getByTestId('edit-poo-modal'))
expect(screen.getByTestId('edit-poo-modal')).toBeTruthy()
})
it('closes EditLocationModal when Cancel is clicked', async () => {
await renderHomePage()
const record = {
person: 'alice',
datetime: '2026-01-15T10:00:00Z',
latitude: 39.9,
longitude: 116.4,
altitude: null,
}
capturedOnSelectLocation!(record)
await waitFor(() => screen.getByTestId('edit-location-modal'))
fireEvent.click(screen.getByTestId('edit-location-cancel'))
await waitFor(() => expect(screen.queryByTestId('edit-location-modal')).toBeNull())
})
it('closes EditPooModal when Cancel is clicked', async () => {
await renderHomePage()
const record = {
timestamp: '2026-01-20T09:00:00Z',
status: 'done',
latitude: 39.91,
longitude: 116.41,
}
capturedOnSelectPoo!(record)
await waitFor(() => screen.getByTestId('edit-poo-modal'))
fireEvent.click(screen.getByTestId('edit-poo-cancel'))
await waitFor(() => expect(screen.queryByTestId('edit-poo-modal')).toBeNull())
})
it('time-range inputs have default values', async () => {
await renderHomePage()
const startInput = screen.getByTestId('time-start-input') as HTMLInputElement
const endInput = screen.getByTestId('time-end-input') as HTMLInputElement
expect(startInput.value).toBeTruthy()
expect(endInput.value).toBeTruthy()
})
it('Apply button re-triggers data fetch with new window', async () => {
await renderHomePage()
const startInput = screen.getByTestId('time-start-input') as HTMLInputElement
fireEvent.change(startInput, { target: { value: '2026-01-01T00:00' } })
fireEvent.click(screen.getByTestId('apply-window-button'))
// Just verify no crash; data refresh happens async via React Query.
await waitFor(() => screen.getByTestId('records-map-mock'))
})
})
+397 -10
View File
@@ -1,19 +1,406 @@
/** /**
* HomePage placeholder for M2-T09. * HomePage data-visualization map view (M2-T09).
* *
* T09 replaces this with the real home view: Leaflet map, heatmap layer, * Renders a heat map of location records (where you've been) and poo records
* time-range selector, scatter-point layer, and poo overlay. * (where the dog poops), plus a toggleable scatter layer for point-select
* edit/delete (reusing T10's modals + hooks).
*
* Data fetching and all state live here; the map itself is fully isolated in
* src/map/RecordsMap.tsx (the ONLY place that imports leaflet).
*/ */
import { Container, Title, Text } from '@mantine/core' import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Stack,
Group,
Switch,
TextInput,
Button,
Select,
ActionIcon,
Tooltip,
Paper,
Text,
Box,
Loader,
Alert,
Badge,
useComputedColorScheme,
} from '@mantine/core'
import { ChevronLeft, ChevronRight } from 'react-feather'
import apiClient from '../api/client'
import {
locationsToHeatPoints,
pooToHeatPoints,
locationsToMapPoints,
pooToMapPoints,
filterPooByTimeWindow,
daysAgoISO,
nowISO,
TIME_PRESETS,
presetRange,
shiftRange,
} from '../map'
import { RecordsMap } from '../map'
import {
EditLocationModal,
EditPooModal,
ConfirmDeleteModal,
useDeleteLocation,
useDeletePoo,
} from '../records'
import type { LocationRecord, PooRecord } from '../records'
// ---------------------------------------------------------------------------
// Data hooks (query-key prefix: ['locations', ...] / ['poo', ...])
// ---------------------------------------------------------------------------
function useLocations(start: string | null, end: string | null) {
return useQuery({
queryKey: ['locations', { start, end, limit: 5000 }],
queryFn: async () => {
const res = await apiClient.GET('/api/locations', {
params: {
query: {
limit: 5000,
offset: 0,
...(start ? { start } : {}),
...(end ? { end } : {}),
},
},
})
return res.data?.items ?? []
},
})
}
/**
* Poo endpoint has no server-side time filter fetch a large page (max 1000)
* and client-filter by timestamp below.
*/
function usePoo() {
return useQuery({
queryKey: ['poo', { limit: 1000 }],
queryFn: async () => {
const res = await apiClient.GET('/api/poo', {
params: { query: { limit: 1000, offset: 0 } },
})
return res.data?.items ?? []
},
})
}
// ---------------------------------------------------------------------------
// Point-select state (which record is selected + which modal to show)
// ---------------------------------------------------------------------------
type SelectionState =
| { kind: 'none' }
| { kind: 'editLocation'; record: LocationRecord }
| { kind: 'deleteLocation'; record: LocationRecord }
| { kind: 'editPoo'; record: PooRecord }
| { kind: 'deletePoo'; record: PooRecord }
// ---------------------------------------------------------------------------
// HomePage
// ---------------------------------------------------------------------------
export function HomePage() { export function HomePage() {
// ------ Time-window state -----------------------------------------------
// Default: last 30 days → now
const [startInput, setStartInput] = useState(() => {
const d = new Date()
d.setUTCDate(d.getUTCDate() - 30)
return d.toISOString().slice(0, 16) // "YYYY-MM-DDTHH:MM"
})
const [endInput, setEndInput] = useState(() => nowISO().slice(0, 16))
// Applied (committed) window — updated on Apply / preset / shift
const [appliedStart, setAppliedStart] = useState<string | null>(() => daysAgoISO(30))
const [appliedEnd, setAppliedEnd] = useState<string | null>(() => nowISO())
// Which quick-range preset is currently active (null = custom / shifted range)
const [activePreset, setActivePreset] = useState<string | null>(null)
// Set both the committed window and the editable inputs from an ISO [start, end].
function setWindow(startISO: string, endISO: string) {
setAppliedStart(startISO)
setAppliedEnd(endISO)
setStartInput(startISO.slice(0, 16))
setEndInput(endISO.slice(0, 16))
}
// Pick a quick range: fill from-to ending at now, apply immediately (Grafana-style).
function applyPreset(value: string | null) {
const preset = TIME_PRESETS.find((p) => p.value === value)
if (!preset) return
const { start, end } = presetRange(preset.spanMs)
setWindow(start, end)
setActivePreset(value)
}
// Shift the committed window by its own span. -1 = earlier, +1 = later.
function shiftWindow(direction: -1 | 1) {
if (!appliedStart || !appliedEnd) return
const { start, end } = shiftRange(appliedStart, appliedEnd, direction)
setWindow(start, end)
// A shifted window is an absolute range, no longer "now - X".
setActivePreset(null)
}
// ------ Layer toggle state -----------------------------------------------
const [showLocationHeat, setShowLocationHeat] = useState(true)
const [showPooHeat, setShowPooHeat] = useState(true)
const [showScatter, setShowScatter] = useState(false)
// ------ Data fetching ----------------------------------------------------
const locationsQuery = useLocations(appliedStart, appliedEnd)
const pooQuery = usePoo()
// Client-side time-filter for poo (server has no filter)
const filteredPoo = useMemo(
() => filterPooByTimeWindow(pooQuery.data ?? [], appliedStart, appliedEnd),
[pooQuery.data, appliedStart, appliedEnd],
)
// Derived map data
const locationHeatPoints = useMemo(
() => locationsToHeatPoints(locationsQuery.data ?? []),
[locationsQuery.data],
)
const pooHeatPoints = useMemo(
() => pooToHeatPoints(filteredPoo),
[filteredPoo],
)
const locationScatterPoints = useMemo(
() => locationsToMapPoints(locationsQuery.data ?? []),
[locationsQuery.data],
)
const pooScatterPoints = useMemo(
() => pooToMapPoints(filteredPoo),
[filteredPoo],
)
// ------ Point-select state -----------------------------------------------
const [selection, setSelection] = useState<SelectionState>({ kind: 'none' })
const deleteLocationMut = useDeleteLocation()
const deletePooMut = useDeletePoo()
// Handlers
function handleSelectLocation(record: LocationRecord) {
setSelection({ kind: 'editLocation', record })
}
function handleSelectPoo(record: PooRecord) {
setSelection({ kind: 'editPoo', record })
}
function applyWindow() {
// Convert local datetime-local inputs (which have no TZ) to ISO8601
// by appending :00Z if needed. Input is "YYYY-MM-DDTHH:MM".
const toISO = (s: string) => (s ? s + ':00Z' : null)
setAppliedStart(toISO(startInput))
setAppliedEnd(toISO(endInput))
// Manually-applied range is custom, not a preset.
setActivePreset(null)
}
// ------ Render -----------------------------------------------------------
const isLoading = locationsQuery.isLoading || pooQuery.isLoading
const isError = locationsQuery.isError || pooQuery.isError
const colorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
return ( return (
<Container pt="xl"> <Box style={{ height: 'calc(100vh - 52px)', display: 'flex', flexDirection: 'column' }}>
<Title order={2}>Home</Title> {/* Controls bar */}
<Text c="dimmed" mt="sm"> <Paper
Map / heatmap visualisation implemented in M2-T09. shadow="xs"
</Text> p="xs"
</Container> style={{ zIndex: 1000, flexShrink: 0 }}
data-testid="map-controls"
>
<Stack gap="xs">
{/* Time-range row */}
<Group gap="xs" align="flex-end" wrap="wrap">
<TextInput
label="From"
type="datetime-local"
value={startInput}
onChange={(e) => setStartInput(e.currentTarget.value)}
size="xs"
style={{ minWidth: 180 }}
data-testid="time-start-input"
/>
<TextInput
label="To"
type="datetime-local"
value={endInput}
onChange={(e) => setEndInput(e.currentTarget.value)}
size="xs"
style={{ minWidth: 180 }}
data-testid="time-end-input"
/>
{/* Quick range + shift buttons (Grafana-style) between To and Apply.
zIndex raised above Leaflet (~1000) so the dropdown/tooltips are
not painted over by the map below. */}
<Group gap={4} align="flex-end">
<Select
label="Quick range"
placeholder="Pick a range"
data={TIME_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
value={activePreset}
onChange={applyPreset}
size="xs"
allowDeselect={false}
style={{ width: 150 }}
comboboxProps={{ zIndex: 3000 }}
data-testid="quick-range-select"
/>
<Tooltip label="Shift earlier (one window back)" zIndex={3000}>
<ActionIcon
variant="default"
size="input-xs"
aria-label="Shift earlier"
onClick={() => shiftWindow(-1)}
data-testid="shift-earlier"
>
<ChevronLeft size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Shift later (one window forward)" zIndex={3000}>
<ActionIcon
variant="default"
size="input-xs"
aria-label="Shift later"
onClick={() => shiftWindow(1)}
data-testid="shift-later"
>
<ChevronRight size={16} />
</ActionIcon>
</Tooltip>
</Group>
<Button size="xs" onClick={applyWindow} data-testid="apply-window-button">
Apply
</Button>
{isLoading && <Loader size="xs" />}
</Group>
{/* Layer toggles row */}
<Group gap="md" wrap="wrap">
<Switch
label={
<Group gap={4}>
<Text size="xs">Location heat</Text>
<Badge size="xs" color="blue" variant="light">
{locationsQuery.data?.length ?? 0}
</Badge>
</Group>
}
checked={showLocationHeat}
onChange={(e) => setShowLocationHeat(e.currentTarget.checked)}
size="xs"
data-testid="toggle-location-heat"
/>
<Switch
label={
<Group gap={4}>
<Text size="xs">Poo heat</Text>
<Badge size="xs" color="orange" variant="light">
{filteredPoo.length}
</Badge>
</Group>
}
checked={showPooHeat}
onChange={(e) => setShowPooHeat(e.currentTarget.checked)}
size="xs"
data-testid="toggle-poo-heat"
/>
<Switch
label={<Text size="xs">Scatter (click to edit)</Text>}
checked={showScatter}
onChange={(e) => setShowScatter(e.currentTarget.checked)}
size="xs"
data-testid="toggle-scatter"
/>
</Group>
{/* Error banner */}
{isError && (
<Alert color="red" data-testid="map-error-alert">
Failed to load data. Check connection and refresh.
</Alert>
)}
</Stack>
</Paper>
{/* Map fills remaining height. `isolation: isolate` traps Leaflet's internal
z-indexes (panes/controls up to ~1000) in their own stacking context so
they can't paint over portaled popups (Quick-range dropdown, tooltips,
and the point-select edit/delete modals). */}
<Box style={{ flex: 1, minHeight: 0, isolation: 'isolate' }}>
<RecordsMap
locationHeatPoints={locationHeatPoints}
pooHeatPoints={pooHeatPoints}
locationScatterPoints={locationScatterPoints}
pooScatterPoints={pooScatterPoints}
showLocationHeat={showLocationHeat}
showPooHeat={showPooHeat}
showScatter={showScatter}
onSelectLocation={handleSelectLocation}
onSelectPoo={handleSelectPoo}
height="100%"
dark={colorScheme === 'dark'}
/>
</Box>
{/* ---------- Point-select modals ---------- */}
{selection.kind === 'editLocation' && (
<EditLocationModal
record={selection.record}
onClose={() => setSelection({ kind: 'none' })}
onSaved={() => setSelection({ kind: 'none' })}
/>
)}
{selection.kind === 'deleteLocation' && (
<ConfirmDeleteModal
message={`Delete location record for ${selection.record.person} at ${selection.record.datetime}?`}
loading={deleteLocationMut.isPending}
onConfirm={async () => {
await deleteLocationMut.mutateAsync({
person: selection.record.person,
datetime: selection.record.datetime,
})
setSelection({ kind: 'none' })
}}
onCancel={() => setSelection({ kind: 'none' })}
/>
)}
{selection.kind === 'editPoo' && (
<EditPooModal
record={selection.record}
onClose={() => setSelection({ kind: 'none' })}
onSaved={() => {
// After saving, optionally switch to delete prompt or just close.
setSelection({ kind: 'none' })
}}
/>
)}
{selection.kind === 'deletePoo' && (
<ConfirmDeleteModal
message={`Delete poo record at ${selection.record.timestamp}?`}
loading={deletePooMut.isPending}
onConfirm={async () => {
await deletePooMut.mutateAsync(selection.record.timestamp)
setSelection({ kind: 'none' })
}}
onCancel={() => setSelection({ kind: 'none' })}
/>
)}
</Box>
) )
} }
+441
View File
@@ -0,0 +1,441 @@
/**
* Tests for RecordsPage (M2-T10).
*
* Coverage:
* 1. Poo list renders from mocked apiClient GET /api/poo.
* 2. Poo pagination: page 2 requests offset=100.
* 3. Edit poo: clicking Edit opens the modal; form submit calls PATCH with raw (un-encoded)
* PK in the path params (openapi-fetch encodes once; we must not double-encode).
* 4. Delete poo: clicking Delete opens confirmation; confirming calls DELETE and refreshes.
* 5. Location list renders from mocked apiClient GET /api/locations.
* 6. Location pagination: page 2 requests offset=100.
* 7. Edit location: clicking Edit opens modal; form submit calls PATCH with raw PK params.
* 8. Delete location: clicking Delete opens confirmation; confirming calls DELETE.
* 9. Real-encoding regression: stub global fetch; verify actual URL uses single encoding
* (%3A present, %253A absent) for PKs containing colons.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { RecordsPage } from './RecordsPage'
import type { LocationRecord } from '../records'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const POO_RECORD = {
timestamp: '2026-06-12T10:00:00Z',
status: 'done',
latitude: 51.5,
longitude: -0.1,
}
const POO_RECORD_2 = {
timestamp: '2026-06-12T11:00:00Z',
status: 'pending',
latitude: 51.6,
longitude: -0.2,
}
const LOCATION_RECORD: LocationRecord = {
person: 'alice',
datetime: '2026-06-12T09:00:00Z',
latitude: 52.0,
longitude: 1.0,
altitude: 10,
}
// Build a page of 100 items (all identical except for timestamp offset).
function makePooPage(offset: number) {
return Array.from({ length: 100 }, (_, i) => ({
timestamp: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`,
status: 'done',
latitude: 51.5,
longitude: -0.1,
}))
}
function makeLocationPage(offset: number): LocationRecord[] {
return Array.from({ length: 100 }, (_, i) => ({
person: 'alice',
datetime: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`,
latitude: 52.0,
longitude: 1.0,
altitude: null,
}))
}
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPatch = vi.fn()
const mockDelete = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
PATCH: (...args: unknown[]) => mockPatch(...args),
DELETE: (...args: unknown[]) => mockDelete(...args),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderRecords() {
return renderWithProviders(<RecordsPage />, { initialPath: '/records' })
}
/** Make GET mock respond based on path. */
function setupGetMock({
pooItems = [POO_RECORD],
locationItems = [LOCATION_RECORD],
pooOffset = 0,
locationOffset = 0,
}: {
pooItems?: typeof POO_RECORD[]
locationItems?: typeof LOCATION_RECORD[]
pooOffset?: number
locationOffset?: number
} = {}) {
mockGet.mockImplementation((path: string, opts?: { params?: { query?: { offset?: number } } }) => {
const offset = opts?.params?.query?.offset ?? 0
if (path === '/api/poo') {
return Promise.resolve({
data: { items: pooItems, limit: 100, offset: pooOffset || offset },
response: { status: 200, ok: true },
})
}
if (path === '/api/locations') {
return Promise.resolve({
data: { items: locationItems, limit: 100, offset: locationOffset || offset },
response: { status: 200, ok: true },
})
}
return Promise.resolve({ data: null })
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('RecordsPage — Poo tab', () => {
beforeEach(() => {
vi.clearAllMocks()
setupGetMock()
})
// -------------------------------------------------------------------------
// 1. Poo list renders
// -------------------------------------------------------------------------
it('renders poo records from GET /api/poo', async () => {
renderRecords()
await waitFor(() => {
expect(screen.getByTestId('poo-table')).toBeInTheDocument()
})
expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument()
expect(screen.getByText('done')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 2. Poo pagination: page 2 sends offset=100
// -------------------------------------------------------------------------
it('requests offset=100 when page 2 is selected', async () => {
// Return full page to trigger pagination display.
const page1 = makePooPage(0)
setupGetMock({ pooItems: page1 })
renderRecords()
await waitFor(() => {
expect(screen.getByTestId('poo-pagination')).toBeInTheDocument()
})
// Click page 2.
const page2Button = screen.getByRole('button', { name: '2' })
fireEvent.click(page2Button)
await waitFor(() => {
const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/poo')
const page2Call = allCalls.find(
(c) => (c[1]?.params?.query?.offset ?? 0) === 100,
)
expect(page2Call).toBeDefined()
})
})
// -------------------------------------------------------------------------
// 3. Edit poo: opens modal, submit calls PATCH with encoded PK
// -------------------------------------------------------------------------
it('opens EditPooModal when Edit is clicked; submit calls PATCH with raw PK in path params and correct body', async () => {
mockPatch.mockResolvedValue({ data: POO_RECORD, response: { status: 200, ok: true } })
renderRecords()
await waitFor(() => {
expect(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`))
// Modal appears
await waitFor(() => {
expect(screen.getByTestId('edit-poo-modal')).toBeInTheDocument()
})
// Change status
const statusInput = screen.getByTestId('poo-status-input') as HTMLInputElement
fireEvent.change(statusInput, { target: { value: 'reviewed' } })
// Submit
fireEvent.submit(screen.getByTestId('edit-poo-form'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalled()
})
const patchCall = mockPatch.mock.calls[0]
expect(patchCall[0]).toBe('/api/poo/{timestamp}')
// PK must be the raw value — openapi-fetch encodes it once; hooks must not pre-encode.
expect(patchCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp)
// Body must include only non-PK fields
expect(patchCall[1].body).toHaveProperty('status')
expect(patchCall[1].body).not.toHaveProperty('timestamp')
})
// -------------------------------------------------------------------------
// 4. Delete poo: confirmation then DELETE called; list refreshes
// -------------------------------------------------------------------------
it('shows confirmation modal on Delete click; DELETE is called after confirmation', async () => {
mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } })
renderRecords()
await waitFor(() => {
expect(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`)).toBeInTheDocument()
})
// Click Delete — confirmation modal appears
fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`))
await waitFor(() => {
expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument()
})
// The modal should show a helpful message
expect(screen.getByTestId('confirm-delete-message')).toBeInTheDocument()
// Cancel first — modal should close
fireEvent.click(screen.getByTestId('confirm-delete-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument()
})
// Reopen and confirm
fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`))
await waitFor(() => {
expect(screen.getByTestId('confirm-delete-confirm')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-delete-confirm'))
await waitFor(() => {
expect(mockDelete).toHaveBeenCalled()
})
const deleteCall = mockDelete.mock.calls[0]
expect(deleteCall[0]).toBe('/api/poo/{timestamp}')
// PK must be the raw value — hooks must not pre-encode; openapi-fetch encodes once.
expect(deleteCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp)
})
})
describe('RecordsPage — Locations tab', () => {
beforeEach(() => {
vi.clearAllMocks()
setupGetMock()
})
// -------------------------------------------------------------------------
// 5. Location list renders
// -------------------------------------------------------------------------
it('renders location records after switching to Locations tab', async () => {
renderRecords()
// Switch to Locations tab
await waitFor(() => {
expect(screen.getByTestId('tab-locations')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('tab-locations'))
await waitFor(() => {
expect(screen.getByTestId('location-table')).toBeInTheDocument()
})
expect(screen.getByText('alice')).toBeInTheDocument()
expect(screen.getByText('2026-06-12T09:00:00Z')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 6. Location pagination: page 2 sends offset=100
// -------------------------------------------------------------------------
it('requests offset=100 for locations when page 2 is selected', async () => {
const page1 = makeLocationPage(0)
setupGetMock({ locationItems: page1 })
renderRecords()
// Switch to Locations tab
await waitFor(() => {
expect(screen.getByTestId('tab-locations')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('tab-locations'))
await waitFor(() => {
expect(screen.getByTestId('location-pagination')).toBeInTheDocument()
})
const page2Button = screen.getByRole('button', { name: '2' })
fireEvent.click(page2Button)
await waitFor(() => {
const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/locations')
const page2Call = allCalls.find(
(c) => (c[1]?.params?.query?.offset ?? 0) === 100,
)
expect(page2Call).toBeDefined()
})
})
// -------------------------------------------------------------------------
// 7. Edit location: opens modal, submit calls PATCH with encoded PK
// -------------------------------------------------------------------------
it('opens EditLocationModal; submit calls PATCH with raw person+datetime in path params', async () => {
mockPatch.mockResolvedValue({ data: LOCATION_RECORD, response: { status: 200, ok: true } })
renderRecords()
fireEvent.click(screen.getByTestId('tab-locations'))
const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}`
await waitFor(() => {
expect(screen.getByTestId(`location-edit-${rowKey}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`location-edit-${rowKey}`))
await waitFor(() => {
expect(screen.getByTestId('edit-location-modal')).toBeInTheDocument()
})
// PK shown read-only in the modal (may appear more than once in the page: table + modal)
const modalEl = screen.getByTestId('edit-location-modal')
expect(modalEl).toBeInTheDocument()
// 'alice' and datetime appear in modal read-only text
expect(modalEl.textContent).toContain('alice')
expect(modalEl.textContent).toContain('2026-06-12T09:00:00Z')
// Submit
fireEvent.submit(screen.getByTestId('edit-location-form'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalled()
})
const patchCall = mockPatch.mock.calls[0]
expect(patchCall[0]).toBe('/api/locations/{person}/{datetime}')
// PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once.
expect(patchCall[1].params.path.person).toBe(LOCATION_RECORD.person)
expect(patchCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime)
// Body must NOT contain PK fields
expect(patchCall[1].body).not.toHaveProperty('person')
expect(patchCall[1].body).not.toHaveProperty('datetime')
expect(patchCall[1].body).toHaveProperty('latitude')
expect(patchCall[1].body).toHaveProperty('longitude')
})
// -------------------------------------------------------------------------
// 8. Delete location: confirmation then DELETE called
// -------------------------------------------------------------------------
it('shows confirmation modal on Delete; DELETE is called with raw PK params', async () => {
mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } })
renderRecords()
fireEvent.click(screen.getByTestId('tab-locations'))
const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}`
await waitFor(() => {
expect(screen.getByTestId(`location-delete-${rowKey}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`location-delete-${rowKey}`))
await waitFor(() => {
expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-delete-confirm'))
await waitFor(() => {
expect(mockDelete).toHaveBeenCalled()
})
const deleteCall = mockDelete.mock.calls[0]
expect(deleteCall[0]).toBe('/api/locations/{person}/{datetime}')
// PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once.
expect(deleteCall[1].params.path.person).toBe(LOCATION_RECORD.person)
expect(deleteCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime)
})
})
// ---------------------------------------------------------------------------
// Additional: multiple poo records with correct timestamps
// ---------------------------------------------------------------------------
describe('RecordsPage — multiple poo rows', () => {
beforeEach(() => {
vi.clearAllMocks()
setupGetMock({ pooItems: [POO_RECORD, POO_RECORD_2] })
})
it('renders both rows', async () => {
renderRecords()
await waitFor(() => {
expect(screen.getByTestId('poo-table')).toBeInTheDocument()
})
expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument()
expect(screen.getByText('2026-06-12T11:00:00Z')).toBeInTheDocument()
})
})
+375
View File
@@ -0,0 +1,375 @@
/**
* RecordsPage paginated lists + edit/delete for poo and location records (M2-T10).
*
* - Poo list: GET /api/poo, query key ['poo', {limit, offset}], page size 100.
* - Location list: GET /api/locations, query key ['locations', {limit, offset}], page size 100.
* - Edit and delete use reusable components from src/records/.
* - Delete has a二次确认 modal before calling DELETE.
* - Pagination with Mantine Pagination; next/prev fetches per-page (no full-table pull).
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Container,
Title,
Table,
Pagination,
Button,
Group,
Tabs,
Text,
Loader,
Center,
Alert,
Stack,
Badge,
ScrollArea,
} from '@mantine/core'
import apiClient from '../api/client'
import { EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records'
import { useDeletePoo, useDeleteLocation } from '../records'
import type { PooRecord, LocationRecord } from '../records'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PAGE_SIZE = 100
// ---------------------------------------------------------------------------
// Poo list section
// ---------------------------------------------------------------------------
function PooList() {
const [page, setPage] = useState(1)
const offset = (page - 1) * PAGE_SIZE
const { data, isLoading, isError } = useQuery({
queryKey: ['poo', { limit: PAGE_SIZE, offset }],
queryFn: async () => {
const res = await apiClient.GET('/api/poo', {
params: { query: { limit: PAGE_SIZE, offset } },
})
return res.data
},
})
const [editRecord, setEditRecord] = useState<PooRecord | null>(null)
const [deleteRecord, setDeleteRecord] = useState<PooRecord | null>(null)
const deleteMutation = useDeletePoo()
async function handleDeleteConfirm() {
if (!deleteRecord) return
try {
await deleteMutation.mutateAsync(deleteRecord.timestamp)
setDeleteRecord(null)
} catch {
// Leave the modal open so the user can retry; error display is in the modal loading state.
}
}
if (isLoading) {
return (
<Center pt="xl" data-testid="poo-loading">
<Loader />
</Center>
)
}
if (isError || !data) {
return (
<Alert color="red" data-testid="poo-load-error">
Failed to load poo records. Please refresh.
</Alert>
)
}
const totalPages = data.items.length === PAGE_SIZE ? page + 1 : page
return (
<Stack gap="md">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed" data-testid="poo-count">
Page {page} · {data.items.length} record{data.items.length !== 1 ? 's' : ''} shown
</Text>
<Badge variant="outline" color="orange">
offset {offset}
</Badge>
</Group>
<ScrollArea>
<Table striped highlightOnHover withTableBorder data-testid="poo-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Timestamp</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Latitude</Table.Th>
<Table.Th>Longitude</Table.Th>
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.items.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Text c="dimmed" ta="center" size="sm">
No records.
</Text>
</Table.Td>
</Table.Tr>
) : (
data.items.map((row) => (
<Table.Tr key={row.timestamp} data-testid={`poo-row-${row.timestamp}`}>
<Table.Td style={{ whiteSpace: 'nowrap' }}>{row.timestamp}</Table.Td>
<Table.Td>{row.status}</Table.Td>
<Table.Td>{row.latitude}</Table.Td>
<Table.Td>{row.longitude}</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="outline"
onClick={() => setEditRecord(row)}
data-testid={`poo-edit-${row.timestamp}`}
>
Edit
</Button>
<Button
size="xs"
variant="outline"
color="red"
onClick={() => setDeleteRecord(row)}
data-testid={`poo-delete-${row.timestamp}`}
>
Delete
</Button>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</ScrollArea>
{totalPages > 1 && (
<Pagination
value={page}
onChange={setPage}
total={totalPages}
data-testid="poo-pagination"
/>
)}
{/* Edit modal */}
{editRecord && (
<EditPooModal
record={editRecord}
onClose={() => setEditRecord(null)}
onSaved={() => setEditRecord(null)}
/>
)}
{/* Delete confirmation modal */}
{deleteRecord && (
<ConfirmDeleteModal
message={`Delete poo record at ${deleteRecord.timestamp}?`}
loading={deleteMutation.isPending}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteRecord(null)}
/>
)}
</Stack>
)
}
// ---------------------------------------------------------------------------
// Location list section
// ---------------------------------------------------------------------------
function LocationList() {
const [page, setPage] = useState(1)
const offset = (page - 1) * PAGE_SIZE
const { data, isLoading, isError } = useQuery({
queryKey: ['locations', { limit: PAGE_SIZE, offset }],
queryFn: async () => {
const res = await apiClient.GET('/api/locations', {
params: { query: { limit: PAGE_SIZE, offset } },
})
return res.data
},
})
const [editRecord, setEditRecord] = useState<LocationRecord | null>(null)
const [deleteRecord, setDeleteRecord] = useState<LocationRecord | null>(null)
const deleteMutation = useDeleteLocation()
async function handleDeleteConfirm() {
if (!deleteRecord) return
try {
await deleteMutation.mutateAsync({
person: deleteRecord.person,
datetime: deleteRecord.datetime,
})
setDeleteRecord(null)
} catch {
// Leave modal open.
}
}
if (isLoading) {
return (
<Center pt="xl" data-testid="location-loading">
<Loader />
</Center>
)
}
if (isError || !data) {
return (
<Alert color="red" data-testid="location-load-error">
Failed to load location records. Please refresh.
</Alert>
)
}
const totalPages = data.items.length === PAGE_SIZE ? page + 1 : page
return (
<Stack gap="md">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed" data-testid="location-count">
Page {page} · {data.items.length} record{data.items.length !== 1 ? 's' : ''} shown
</Text>
<Badge variant="outline" color="blue">
offset {offset}
</Badge>
</Group>
<ScrollArea>
<Table striped highlightOnHover withTableBorder data-testid="location-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Person</Table.Th>
<Table.Th>Datetime</Table.Th>
<Table.Th>Latitude</Table.Th>
<Table.Th>Longitude</Table.Th>
<Table.Th>Altitude</Table.Th>
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.items.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" size="sm">
No records.
</Text>
</Table.Td>
</Table.Tr>
) : (
data.items.map((row) => {
const rowKey = `${row.person}__${row.datetime}`
return (
<Table.Tr key={rowKey} data-testid={`location-row-${rowKey}`}>
<Table.Td>{row.person}</Table.Td>
<Table.Td style={{ whiteSpace: 'nowrap' }}>{row.datetime}</Table.Td>
<Table.Td>{row.latitude}</Table.Td>
<Table.Td>{row.longitude}</Table.Td>
<Table.Td>{row.altitude ?? '—'}</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="outline"
onClick={() => setEditRecord(row)}
data-testid={`location-edit-${rowKey}`}
>
Edit
</Button>
<Button
size="xs"
variant="outline"
color="red"
onClick={() => setDeleteRecord(row)}
data-testid={`location-delete-${rowKey}`}
>
Delete
</Button>
</Group>
</Table.Td>
</Table.Tr>
)
})
)}
</Table.Tbody>
</Table>
</ScrollArea>
{totalPages > 1 && (
<Pagination
value={page}
onChange={setPage}
total={totalPages}
data-testid="location-pagination"
/>
)}
{/* Edit modal */}
{editRecord && (
<EditLocationModal
record={editRecord}
onClose={() => setEditRecord(null)}
onSaved={() => setEditRecord(null)}
/>
)}
{/* Delete confirmation modal */}
{deleteRecord && (
<ConfirmDeleteModal
message={`Delete location record for ${deleteRecord.person} at ${deleteRecord.datetime}?`}
loading={deleteMutation.isPending}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteRecord(null)}
/>
)}
</Stack>
)
}
// ---------------------------------------------------------------------------
// RecordsPage — top-level
// ---------------------------------------------------------------------------
export function RecordsPage() {
return (
<Container size="xl" pt="xl" pb="xl" data-testid="records-page">
<Title order={2} mb="lg">
Records
</Title>
<Tabs defaultValue="poo">
<Tabs.List mb="md">
<Tabs.Tab value="poo" data-testid="tab-poo">
Poo
</Tabs.Tab>
<Tabs.Tab value="locations" data-testid="tab-locations">
Locations
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="poo">
<PooList />
</Tabs.Panel>
<Tabs.Panel value="locations">
<LocationList />
</Tabs.Panel>
</Tabs>
</Container>
)
}
@@ -0,0 +1,47 @@
/**
* ConfirmDeleteModal generic二次确认 (confirm-before-delete) dialog.
* Used by both poo and location delete flows (M2-T10, reused by T09).
*/
import { Modal, Stack, Text, Button, Group } from '@mantine/core'
export interface ConfirmDeleteModalProps {
/** Message shown to the user, e.g. "Delete this poo record?" */
message: string
/** Whether the delete action is in flight. */
loading?: boolean
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDeleteModal({
message,
loading = false,
onConfirm,
onCancel,
}: ConfirmDeleteModalProps) {
return (
<Modal opened onClose={onCancel} title="Confirm Delete" size="sm" data-testid="confirm-delete-modal">
<Stack gap="md">
<Text data-testid="confirm-delete-message">{message}</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={onCancel}
data-testid="confirm-delete-cancel"
>
Cancel
</Button>
<Button
color="red"
loading={loading}
onClick={onConfirm}
data-testid="confirm-delete-confirm"
>
Delete
</Button>
</Group>
</Stack>
</Modal>
)
}
+141
View File
@@ -0,0 +1,141 @@
/**
* EditLocationModal edit non-PK fields of a location record (M2-T10, reused by T09).
*
* Editable fields: latitude, longitude, altitude.
* Read-only: person + datetime (composite PK).
*/
import { useState } from 'react'
import {
Modal,
Stack,
NumberInput,
Button,
Group,
Text,
Alert,
} from '@mantine/core'
import { useUpdateLocation } from './hooks'
import type { LocationRecord, LocationUpdateBody } from './hooks'
export interface EditLocationModalProps {
record: LocationRecord
onClose: () => void
onSaved: () => void
}
export function EditLocationModal({ record, onClose, onSaved }: EditLocationModalProps) {
const [latitude, setLatitude] = useState<number | string>(record.latitude)
const [longitude, setLongitude] = useState<number | string>(record.longitude)
const [altitude, setAltitude] = useState<number | string>(record.altitude ?? '')
const [error, setError] = useState<string | null>(null)
const updateMutation = useUpdateLocation()
function validate(): string | null {
const lat = Number(latitude)
const lng = Number(longitude)
if (isNaN(lat) || lat < -90 || lat > 90) return 'Latitude must be a number between -90 and 90.'
if (isNaN(lng) || lng < -180 || lng > 180)
return 'Longitude must be a number between -180 and 180.'
// Altitude is optional — blank is fine.
if (altitude !== '' && altitude !== null && isNaN(Number(altitude)))
return 'Altitude must be a number or left blank.'
return null
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
const validationError = validate()
if (validationError) {
setError(validationError)
return
}
const body: LocationUpdateBody = {
latitude: Number(latitude),
longitude: Number(longitude),
altitude: altitude === '' || altitude === null ? null : Number(altitude),
}
try {
await updateMutation.mutateAsync({
person: record.person,
datetime: record.datetime,
body,
})
onSaved()
onClose()
} catch {
setError('Failed to save. Please try again.')
}
}
return (
<Modal
opened
onClose={onClose}
title="Edit Location Record"
size="sm"
data-testid="edit-location-modal"
>
<form onSubmit={handleSubmit} data-testid="edit-location-form">
<Stack gap="sm">
{/* Composite PK — read-only */}
<Text size="sm" c="dimmed">
<strong>Person (PK):</strong> {record.person}
</Text>
<Text size="sm" c="dimmed">
<strong>Datetime (PK):</strong> {record.datetime}
</Text>
<NumberInput
label="Latitude"
value={latitude}
onChange={(val) => setLatitude(val)}
decimalScale={6}
data-testid="location-latitude-input"
/>
<NumberInput
label="Longitude"
value={longitude}
onChange={(val) => setLongitude(val)}
decimalScale={6}
data-testid="location-longitude-input"
/>
<NumberInput
label="Altitude (optional)"
value={altitude}
onChange={(val) => setAltitude(val)}
decimalScale={2}
placeholder="Leave blank to clear"
data-testid="location-altitude-input"
/>
{error && (
<Alert color="red" data-testid="edit-location-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} data-testid="edit-location-cancel">
Cancel
</Button>
<Button
type="submit"
loading={updateMutation.isPending}
data-testid="edit-location-submit"
>
Save
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
+130
View File
@@ -0,0 +1,130 @@
/**
* EditPooModal edit non-PK fields of a poo record (M2-T10, reused by T09).
*
* Editable fields: status, latitude, longitude.
* Read-only: timestamp (PK).
*/
import { useState } from 'react'
import {
Modal,
Stack,
TextInput,
NumberInput,
Button,
Group,
Text,
Alert,
} from '@mantine/core'
import { useUpdatePoo } from './hooks'
import type { PooRecord, PooUpdateBody } from './hooks'
export interface EditPooModalProps {
record: PooRecord
onClose: () => void
onSaved: () => void
}
export function EditPooModal({ record, onClose, onSaved }: EditPooModalProps) {
const [status, setStatus] = useState(record.status)
const [latitude, setLatitude] = useState<number | string>(record.latitude)
const [longitude, setLongitude] = useState<number | string>(record.longitude)
const [error, setError] = useState<string | null>(null)
const updateMutation = useUpdatePoo()
function validate(): string | null {
const lat = Number(latitude)
const lng = Number(longitude)
if (isNaN(lat) || lat < -90 || lat > 90) return 'Latitude must be a number between -90 and 90.'
if (isNaN(lng) || lng < -180 || lng > 180)
return 'Longitude must be a number between -180 and 180.'
return null
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
const validationError = validate()
if (validationError) {
setError(validationError)
return
}
const body: PooUpdateBody = {
status: status || undefined,
latitude: Number(latitude),
longitude: Number(longitude),
}
try {
await updateMutation.mutateAsync({ timestamp: record.timestamp, body })
onSaved()
onClose()
} catch {
setError('Failed to save. Please try again.')
}
}
return (
<Modal
opened
onClose={onClose}
title="Edit Poo Record"
size="sm"
data-testid="edit-poo-modal"
>
<form onSubmit={handleSubmit} data-testid="edit-poo-form">
<Stack gap="sm">
{/* PK — read-only */}
<Text size="sm" c="dimmed">
<strong>Timestamp (PK):</strong> {record.timestamp}
</Text>
<TextInput
label="Status"
value={status}
onChange={(e) => setStatus(e.currentTarget.value)}
data-testid="poo-status-input"
/>
<NumberInput
label="Latitude"
value={latitude}
onChange={(val) => setLatitude(val)}
decimalScale={6}
data-testid="poo-latitude-input"
/>
<NumberInput
label="Longitude"
value={longitude}
onChange={(val) => setLongitude(val)}
decimalScale={6}
data-testid="poo-longitude-input"
/>
{error && (
<Alert color="red" data-testid="edit-poo-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} data-testid="edit-poo-cancel">
Cancel
</Button>
<Button
type="submit"
loading={updateMutation.isPending}
data-testid="edit-poo-submit"
>
Save
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Real-encoding regression test for M2-T10 (REWORK 1).
*
* Motivation: RecordsPage.test.tsx mocks the entire apiClient module, so
* openapi-fetch's defaultPathSerializer never runs in those tests. That means
* the integration between hooks.ts and the real client cannot be verified there.
*
* This file uses two complementary strategies:
*
* A) Direct serializer test import openapi-fetch's defaultPathSerializer and
* verify that raw PK values (with ':') produce single-encoded URLs (%3A,
* NOT %253A). This is a pure-function test with no network I/O.
*
* B) Live fetch stub create a real openapi-fetch client instance with a
* custom fetch stub, call the same path that hooks.ts calls (with a raw PK),
* and assert the URL the client constructs contains exactly one level of
* encoding. This exercises the full openapi-fetch URL-construction path.
*
* Together these prove:
* 1. openapi-fetch encodes raw ':' correctly (as '%3A', once).
* 2. The path template /api/poo/{timestamp} with a raw timestamp produces
* the right URL and would break if encodeURIComponent were applied first.
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
import createClient, { defaultPathSerializer } from 'openapi-fetch'
import type { paths } from '../api/schema.d.ts'
afterEach(() => {
vi.unstubAllGlobals()
})
// ---------------------------------------------------------------------------
// A) defaultPathSerializer unit tests
// ---------------------------------------------------------------------------
describe('openapi-fetch defaultPathSerializer (raw PK → single-encoded URL)', () => {
it('encodes a poo timestamp with colons exactly once', () => {
const template = '/api/poo/{timestamp}'
const rawTs = '2026-06-12T10:00:00Z'
const result = defaultPathSerializer(template, { timestamp: rawTs })
// Single-encoded colon
expect(result).toContain('%3A')
// Double-encoded colon must NOT appear
expect(result).not.toContain('%253A')
expect(result).toBe('/api/poo/2026-06-12T10%3A00%3A00Z')
})
it('encodes location person+datetime with colons exactly once', () => {
const template = '/api/locations/{person}/{datetime}'
const rawDt = '2026-06-12T09:00:00Z'
const result = defaultPathSerializer(template, { person: 'alice', datetime: rawDt })
expect(result).toContain('%3A')
expect(result).not.toContain('%253A')
expect(result).toBe('/api/locations/alice/2026-06-12T09%3A00%3A00Z')
})
it('pre-encoding a PK before passing it causes double-encoding (%253A)', () => {
// This test documents the BUG that was present before REWORK 1:
// hooks.ts was calling encodeURIComponent(timestamp) before passing to
// the client, so defaultPathSerializer would encode it a second time.
const template = '/api/poo/{timestamp}'
const rawTs = '2026-06-12T10:00:00Z'
const preEncoded = encodeURIComponent(rawTs) // what the old hooks.ts did
const result = defaultPathSerializer(template, { timestamp: preEncoded })
// Double-encoded: '%' → '%25', then '3A' stays → '%253A'
expect(result).toContain('%253A')
// This is WRONG — after fix, hooks must NOT pre-encode.
})
})
// ---------------------------------------------------------------------------
// B) Live fetch-stub test using a real openapi-fetch client instance
// ---------------------------------------------------------------------------
describe('real openapi-fetch client URL construction (fetch-stub)', () => {
it('DELETE /api/poo/{timestamp} with raw PK produces single-encoded URL', async () => {
const capturedUrls: string[] = []
const fakeFetch = vi.fn((_input: RequestInfo | URL) => {
const url =
typeof _input === 'string'
? _input
: _input instanceof URL
? _input.href
: (_input as Request).url
capturedUrls.push(url)
return Promise.resolve(new Response(null, { status: 204 }))
})
// Create a real client with our fake fetch — same config as client.ts
// but with an explicit fetch override so we control the transport.
const testClient = createClient<paths>({
baseUrl: 'http://localhost/',
fetch: fakeFetch as typeof fetch,
})
const rawTs = '2026-06-12T10:00:00Z'
await testClient.DELETE('/api/poo/{timestamp}', {
params: { path: { timestamp: rawTs } },
})
expect(fakeFetch).toHaveBeenCalled()
const url = capturedUrls[0]
expect(url).toBeDefined()
// Single-encoded colon: present
expect(url).toContain('%3A')
// Double-encoded colon: must be absent
expect(url).not.toContain('%253A')
expect(url).toContain('/api/poo/2026-06-12T10%3A00%3A00Z')
})
it('DELETE /api/locations/{person}/{datetime} with raw PK produces single-encoded URL', async () => {
const capturedUrls: string[] = []
const fakeFetch = vi.fn((_input: RequestInfo | URL) => {
const url =
typeof _input === 'string'
? _input
: _input instanceof URL
? _input.href
: (_input as Request).url
capturedUrls.push(url)
return Promise.resolve(new Response(null, { status: 204 }))
})
const testClient = createClient<paths>({
baseUrl: 'http://localhost/',
fetch: fakeFetch as typeof fetch,
})
const rawDt = '2026-06-12T09:00:00Z'
await testClient.DELETE('/api/locations/{person}/{datetime}', {
params: { path: { person: 'alice', datetime: rawDt } },
})
expect(fakeFetch).toHaveBeenCalled()
const url = capturedUrls[0]
expect(url).toBeDefined()
expect(url).toContain('%3A')
expect(url).not.toContain('%253A')
expect(url).toContain('/api/locations/alice/2026-06-12T09%3A00%3A00Z')
})
it('double-encoded PK produces wrong URL — documents the fixed bug', async () => {
// This test shows what the OLD hooks.ts would produce.
// It is intentionally asserting the BAD behavior to document the regression.
const capturedUrls: string[] = []
const fakeFetch = vi.fn((_input: RequestInfo | URL) => {
const url =
typeof _input === 'string'
? _input
: _input instanceof URL
? _input.href
: (_input as Request).url
capturedUrls.push(url)
return Promise.resolve(new Response(null, { status: 204 }))
})
const testClient = createClient<paths>({
baseUrl: 'http://localhost/',
fetch: fakeFetch as typeof fetch,
})
const rawTs = '2026-06-12T10:00:00Z'
// Simulate what the old hooks.ts did: pre-encode before passing to client
const preEncoded = encodeURIComponent(rawTs)
await testClient.DELETE('/api/poo/{timestamp}', {
params: { path: { timestamp: preEncoded } },
})
const url = capturedUrls[0]
// The OLD code would produce double-encoding (%253A), which caused 404 on the backend
expect(url).toContain('%253A')
})
})
+98
View File
@@ -0,0 +1,98 @@
/**
* Reusable mutation hooks for poo and location CRUD (M2-T10, reused by T09).
*
* Contract (orchestrator-decisions.md §13):
* - useUpdatePoo / useDeletePoo PK = timestamp, path /api/poo/{timestamp}
* - useUpdateLocation / useDeleteLocation PK = person+datetime, path /api/locations/{person}/{datetime}
* - Path params are passed as raw strings; openapi-fetch's defaultPathSerializer
* already calls encodeURIComponent once per simple {param} segment.
* Do NOT call encodeURIComponent here that would produce double-encoding.
* - On success each hook invalidates the shared query-key prefix ('poo' or 'locations')
* so both list and map views refresh automatically.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import apiClient from '../api/client'
import type { components } from '../api/schema.d.ts'
// Re-export record types so T09 can import them from one place.
export type PooRecord = components['schemas']['PooRecord']
export type LocationRecord = components['schemas']['LocationRecord']
export type PooUpdateBody = components['schemas']['PooUpdateRequest']
export type LocationUpdateBody = components['schemas']['LocationUpdateRequest']
// ---------------------------------------------------------------------------
// Poo hooks
// ---------------------------------------------------------------------------
/** Update non-PK fields of a single poo record. */
export function useUpdatePoo() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ timestamp, body }: { timestamp: string; body: PooUpdateBody }) =>
apiClient.PATCH('/api/poo/{timestamp}', {
params: { path: { timestamp } },
body,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }),
})
}
/** Delete a single poo record by its PK (timestamp). */
export function useDeletePoo() {
const qc = useQueryClient()
return useMutation({
mutationFn: (timestamp: string) =>
apiClient.DELETE('/api/poo/{timestamp}', {
params: { path: { timestamp } },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }),
})
}
// ---------------------------------------------------------------------------
// Location hooks
// ---------------------------------------------------------------------------
/** Update non-PK fields of a single location record. */
export function useUpdateLocation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
person,
datetime,
body,
}: {
person: string
datetime: string
body: LocationUpdateBody
}) =>
apiClient.PATCH('/api/locations/{person}/{datetime}', {
params: {
path: {
person,
datetime,
},
},
body,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['locations'] }),
})
}
/** Delete a single location record by its composite PK (person + datetime). */
export function useDeleteLocation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ person, datetime }: { person: string; datetime: string }) =>
apiClient.DELETE('/api/locations/{person}/{datetime}', {
params: {
path: {
person,
datetime,
},
},
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['locations'] }),
})
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Public surface of the records module (M2-T10).
*
* T09 (map) imports from here:
* import { useUpdatePoo, useDeletePoo, useUpdateLocation, useDeleteLocation,
* EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records'
* import type { PooRecord, LocationRecord } from '../records'
*/
// Hooks
export { useUpdatePoo, useDeletePoo, useUpdateLocation, useDeleteLocation } from './hooks'
// Types
export type { PooRecord, LocationRecord, PooUpdateBody, LocationUpdateBody } from './hooks'
// Modals
export { EditPooModal } from './EditPooModal'
export type { EditPooModalProps } from './EditPooModal'
export { EditLocationModal } from './EditLocationModal'
export type { EditLocationModalProps } from './EditLocationModal'
export { ConfirmDeleteModal } from './ConfirmDeleteModal'
export type { ConfirmDeleteModalProps } from './ConfirmDeleteModal'
-307
View File
@@ -27,249 +27,6 @@
} }
} }
}, },
"/login": {
"get": {
"tags": [
"auth"
],
"summary": "Login Page",
"operationId": "login_page_login_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
},
"post": {
"tags": [
"auth"
],
"summary": "Login Submit",
"operationId": "login_submit_login_post",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/Body_login_submit_login_post"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/config/change-password": {
"post": {
"tags": [
"auth"
],
"summary": "Change Password Submit",
"operationId": "change_password_submit_config_change_password_post",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/Body_change_password_submit_config_change_password_post"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/logout": {
"post": {
"tags": [
"auth"
],
"summary": "Logout",
"operationId": "logout_logout_post",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/Body_logout_logout_post"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/": {
"get": {
"tags": [
"pages"
],
"summary": "Home",
"operationId": "home__get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/admin": {
"get": {
"tags": [
"pages"
],
"summary": "Admin Redirect",
"operationId": "admin_redirect_admin_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/config": {
"get": {
"tags": [
"pages"
],
"summary": "Config Page",
"operationId": "config_page_config_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
},
"post": {
"tags": [
"pages"
],
"summary": "Config Submit",
"operationId": "config_submit_config_post",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/config/smtp/test": {
"post": {
"tags": [
"pages"
],
"summary": "Smtp Test Submit",
"operationId": "smtp_test_submit_config_smtp_test_post",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/api/config": { "/api/config": {
"get": { "get": {
"tags": [ "tags": [
@@ -1176,70 +933,6 @@
}, },
"components": { "components": {
"schemas": { "schemas": {
"Body_change_password_submit_config_change_password_post": {
"properties": {
"current_password": {
"type": "string",
"title": "Current Password"
},
"new_password": {
"type": "string",
"title": "New Password"
},
"confirm_password": {
"type": "string",
"title": "Confirm Password"
},
"csrf_token": {
"type": "string",
"title": "Csrf Token"
}
},
"type": "object",
"required": [
"current_password",
"new_password",
"confirm_password",
"csrf_token"
],
"title": "Body_change_password_submit_config_change_password_post"
},
"Body_login_submit_login_post": {
"properties": {
"username": {
"type": "string",
"title": "Username"
},
"password": {
"type": "string",
"title": "Password"
},
"csrf_token": {
"type": "string",
"title": "Csrf Token"
}
},
"type": "object",
"required": [
"username",
"password",
"csrf_token"
],
"title": "Body_login_submit_login_post"
},
"Body_logout_logout_post": {
"properties": {
"csrf_token": {
"type": "string",
"title": "Csrf Token"
}
},
"type": "object",
"required": [
"csrf_token"
],
"title": "Body_logout_logout_post"
},
"ConfigField": { "ConfigField": {
"properties": { "properties": {
"env_name": { "env_name": {
-197
View File
@@ -18,156 +18,6 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/StatusResponse' $ref: '#/components/schemas/StatusResponse'
/login:
get:
tags:
- auth
summary: Login Page
operationId: login_page_login_get
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
post:
tags:
- auth
summary: Login Submit
operationId: login_submit_login_post
requestBody:
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Body_login_submit_login_post'
required: true
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/config/change-password:
post:
tags:
- auth
summary: Change Password Submit
operationId: change_password_submit_config_change_password_post
requestBody:
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Body_change_password_submit_config_change_password_post'
required: true
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/logout:
post:
tags:
- auth
summary: Logout
operationId: logout_logout_post
requestBody:
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Body_logout_logout_post'
required: true
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/:
get:
tags:
- pages
summary: Home
operationId: home__get
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
/admin:
get:
tags:
- pages
summary: Admin Redirect
operationId: admin_redirect_admin_get
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
/config:
get:
tags:
- pages
summary: Config Page
operationId: config_page_config_get
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
post:
tags:
- pages
summary: Config Submit
operationId: config_submit_config_post
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
/config/smtp/test:
post:
tags:
- pages
summary: Smtp Test Submit
operationId: smtp_test_submit_config_smtp_test_post
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
/api/config: /api/config:
get: get:
tags: tags:
@@ -812,53 +662,6 @@ paths:
schema: {} schema: {}
components: components:
schemas: schemas:
Body_change_password_submit_config_change_password_post:
properties:
current_password:
type: string
title: Current Password
new_password:
type: string
title: New Password
confirm_password:
type: string
title: Confirm Password
csrf_token:
type: string
title: Csrf Token
type: object
required:
- current_password
- new_password
- confirm_password
- csrf_token
title: Body_change_password_submit_config_change_password_post
Body_login_submit_login_post:
properties:
username:
type: string
title: Username
password:
type: string
title: Password
csrf_token:
type: string
title: Csrf Token
type: object
required:
- username
- password
- csrf_token
title: Body_login_submit_login_post
Body_logout_logout_post:
properties:
csrf_token:
type: string
title: Csrf Token
type: object
required:
- csrf_token
title: Body_logout_logout_post
ConfigField: ConfigField:
properties: properties:
env_name: env_name:
-1
View File
@@ -3,7 +3,6 @@ apscheduler>=3.10,<4.0
argon2-cffi>=25.1,<26.0 argon2-cffi>=25.1,<26.0
fastapi>=0.115,<0.116 fastapi>=0.115,<0.116
httpx>=0.28,<1.0 httpx>=0.28,<1.0
jinja2>=3.1,<4.0
pydantic-settings>=2.6,<3.0 pydantic-settings>=2.6,<3.0
python-multipart>=0.0.12,<1.0 python-multipart>=0.0.12,<1.0
pyyaml>=6.0,<7.0 pyyaml>=6.0,<7.0
+1 -5
View File
@@ -45,14 +45,10 @@ idna==3.11
# via # via
# anyio # anyio
# httpx # httpx
jinja2==3.1.6
# via -r requirements.in
mako==1.3.11 mako==1.3.11
# via alembic # via alembic
markupsafe==3.0.3 markupsafe==3.0.3
# via # via mako
# jinja2
# mako
pycparser==2.23 pycparser==2.23
# via cffi # via cffi
pydantic==2.13.2 pydantic==2.13.2
+4 -18
View File
@@ -2,7 +2,6 @@
Tests for M2-T05: POST /api/config/smtp/test.""" Tests for M2-T05: POST /api/config/smtp/test."""
from __future__ import annotations from __future__ import annotations
import re
import sqlite3 import sqlite3
from unittest.mock import patch from unittest.mock import patch
@@ -17,26 +16,13 @@ from app.services.email import EmailConfigurationError, EmailDeliveryError
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _extract_csrf_token(html: str) -> str:
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert match is not None, "csrf_token not found in HTML"
return match.group(1)
def _login(client: TestClient) -> None: def _login(client: TestClient) -> None:
"""Log in as admin/test-password using the Jinja login form.""" """Log in as admin/test-password using the JSON API."""
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
resp = client.post( resp = client.post(
"/login", "/api/auth/login",
data={ json={"username": "admin", "password": "test-password"},
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
) )
assert resp.status_code == 303, f"Login failed: {resp.status_code}" assert resp.status_code == 200, f"Login failed: {resp.status_code}"
def _stringify(value) -> str: def _stringify(value) -> str:
-7
View File
@@ -1,7 +1,6 @@
"""Tests for M2-T03: GET /api/locations, GET /api/poo, GET /api/public-ip.""" """Tests for M2-T03: GET /api/locations, GET /api/poo, GET /api/public-ip."""
from __future__ import annotations from __future__ import annotations
import re
from datetime import UTC, datetime from datetime import UTC, datetime
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -18,12 +17,6 @@ from app.models.public_ip import PublicIPHistory, PublicIPState
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _extract_csrf_token(html: str) -> str:
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert match is not None, "csrf_token not found in HTML"
return match.group(1)
def _api_login(client: TestClient) -> None: def _api_login(client: TestClient) -> None:
"""Log in via POST /api/auth/login so the TestClient has a session cookie.""" """Log in via POST /api/auth/login so the TestClient has a session cookie."""
resp = client.post( resp = client.post(
+2 -22
View File
@@ -1,8 +1,6 @@
"""Tests for M2-T02: GET /api/session, POST /api/auth/login, /logout, /password.""" """Tests for M2-T02: GET /api/session, POST /api/auth/login, /logout, /password."""
from __future__ import annotations from __future__ import annotations
import re
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -11,24 +9,6 @@ from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _extract_csrf_token(html: str) -> str:
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert match is not None, "csrf_token not found in HTML"
return match.group(1)
def _jinja_login(client: TestClient) -> None:
"""Log in via the existing Jinja form so the client has a session cookie."""
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
resp = client.post(
"/login",
data={"username": "admin", "password": "test-password", "csrf_token": csrf_token},
follow_redirects=False,
)
assert resp.status_code == 303, f"Jinja login failed: {resp.status_code}"
def _api_login(client: TestClient, *, username: str = "admin", password: str = "test-password"): def _api_login(client: TestClient, *, username: str = "admin", password: str = "test-password"):
"""Log in via POST /api/auth/login and return the response.""" """Log in via POST /api/auth/login and return the response."""
return client.post( return client.post(
@@ -53,7 +33,7 @@ def test_get_session_unauthenticated_returns_401(client: TestClient) -> None:
def test_get_session_authenticated_returns_user_and_csrf(client: TestClient) -> None: def test_get_session_authenticated_returns_user_and_csrf(client: TestClient) -> None:
_jinja_login(client) _api_login(client)
response = client.get("/api/session") response = client.get("/api/session")
@@ -68,7 +48,7 @@ def test_get_session_authenticated_returns_user_and_csrf(client: TestClient) ->
def test_get_session_does_not_leak_password(client: TestClient) -> None: def test_get_session_does_not_leak_password(client: TestClient) -> None:
_jinja_login(client) _api_login(client)
response = client.get("/api/session") response = client.get("/api/session")
body_str = str(response.json()) body_str = str(response.json())
assert "test-password" not in body_str assert "test-password" not in body_str
+4 -2
View File
@@ -25,9 +25,11 @@ def _prepare_app_db(tmp_path) -> str:
def test_app_starts(client: TestClient) -> None: def test_app_starts(client: TestClient) -> None:
# With SPA enabled, GET / is served by the catch-all and returns index.html (200).
# Without SPA (e.g. SPA_DIST_DIR points to empty dir), it returns 404.
# Either way the app started successfully — just assert it is not a server error.
response = client.get("/", follow_redirects=False) response = client.get("/", follow_redirects=False)
assert response.status_code == 303 assert response.status_code in (200, 404)
assert response.headers["location"] == "/login"
def test_status_endpoint(client: TestClient) -> None: def test_status_endpoint(client: TestClient) -> None:
-265
View File
@@ -1,265 +0,0 @@
import re
import sqlite3
from fastapi.testclient import TestClient
from app.db import reset_db_caches
from app.config import get_settings
from app.main import create_app
def _extract_csrf_token(html: str) -> str:
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert match is not None
return match.group(1)
def _stringify_for_form(value) -> str:
if value is None:
return ""
if isinstance(value, bool):
return str(value).lower()
return str(value)
def test_unauthenticated_config_redirects_to_login(client: TestClient) -> None:
response = client.get("/config", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestClient) -> None:
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
response = client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/config"
set_cookie_header = response.headers["set-cookie"].lower()
assert "home_automation_session=" in set_cookie_header
assert "httponly" in set_cookie_header
assert "samesite=lax" in set_cookie_header
config_response = client.get("/config")
assert config_response.status_code == 200
assert "首次登录后需要先修改密码" in config_response.text
assert "Current Password" in config_response.text
assert "New Password" in config_response.text
assert "Save Config" in config_response.text
assert "当前用户" in config_response.text
assert "Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth." in config_response.text
assert 'aria-disabled="true">Authorize TickTick<' in config_response.text
def test_login_failure_returns_generic_error(client: TestClient) -> None:
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
response = client.post(
"/login",
data={
"username": "admin",
"password": "wrong-password",
"csrf_token": csrf_token,
},
)
assert response.status_code == 401
assert "invalid username or password" in response.text
assert "wrong-password" not in response.text
def test_logout_revokes_session(client: TestClient) -> None:
login_page = client.get("/login")
login_csrf_token = _extract_csrf_token(login_page.text)
client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": login_csrf_token,
},
)
config_page = client.get("/config")
logout_csrf_token = _extract_csrf_token(config_page.text)
logout_response = client.post(
"/logout",
data={"csrf_token": logout_csrf_token},
follow_redirects=False,
)
assert logout_response.status_code == 303
assert logout_response.headers["location"] == "/login"
config_after_logout = client.get("/config", follow_redirects=False)
assert config_after_logout.status_code == 303
assert config_after_logout.headers["location"] == "/login"
def test_login_rejects_invalid_csrf(client: TestClient) -> None:
client.get("/login")
response = client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": "wrong-csrf",
},
)
assert response.status_code == 400
assert "invalid login request" in response.text
def test_legacy_admin_route_redirects_to_config_when_authenticated(client: TestClient) -> None:
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
response = client.get("/admin", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/config"
def test_config_page_update_persists_to_database(
client: TestClient, test_database_urls
) -> None:
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
config_page = client.get("/config")
config_csrf_token = _extract_csrf_token(config_page.text)
settings = get_settings()
form_data = {"csrf_token": config_csrf_token}
from app.services.config_page import CONFIG_FIELDS
for field in CONFIG_FIELDS:
if field.secret:
form_data[field.env_name] = ""
else:
form_data[field.env_name] = _stringify_for_form(getattr(settings, field.setting_attr))
form_data["APP_NAME"] = "Updated Home Automation"
form_data["HOME_ASSISTANT_AUTH_TOKEN"] = "new-token"
response = client.post("/config", data=form_data, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/config?saved=1"
conn = sqlite3.connect(test_database_urls["app_path"])
try:
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
finally:
conn.close()
assert rows["APP_NAME"] == "Updated Home Automation"
assert rows["HOME_ASSISTANT_AUTH_TOKEN"] == "new-token"
assert "AUTH_BOOTSTRAP_USERNAME" not in rows
def test_config_page_shows_ticktick_oauth_link_when_ticktick_is_configured(
auth_database,
monkeypatch,
) -> None:
monkeypatch.setenv("APP_ENV", "production")
monkeypatch.setenv("APP_HOSTNAME", "localhost:8000")
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
get_settings.cache_clear()
reset_db_caches()
with TestClient(create_app()) as client:
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
config_response = client.get("/config")
assert config_response.status_code == 200
assert "Use the saved TickTick client settings to start the authorization flow." in config_response.text
assert "Redirect URI: https://localhost:8000/ticktick/auth/code" in config_response.text
assert 'href="/ticktick/auth/start">Authorize TickTick<' in config_response.text
def test_config_page_shows_ticktick_oauth_success_notice(client: TestClient) -> None:
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
response = client.get("/config?ticktick_oauth=success")
assert response.status_code == 200
assert "TickTick authorization completed successfully." in response.text
def test_config_page_shows_ticktick_oauth_failure_notice(client: TestClient) -> None:
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
response = client.get("/config?ticktick_oauth=failed")
assert response.status_code == 200
assert "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI." in response.text
+88 -9
View File
@@ -14,8 +14,25 @@ from scripts.run_migrations import run_all_migrations
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
class _ComposeLoader(yaml.SafeLoader):
"""SafeLoader that tolerates docker-compose merge tags (e.g. ``!override``,
``!reset``), which appear in docker-compose.dev.yml's ``ports`` and which
plain ``safe_load`` rejects as unknown constructors."""
def _construct_compose_tag(loader: yaml.Loader, _suffix: str, node: yaml.Node):
if isinstance(node, yaml.MappingNode):
return loader.construct_mapping(node, deep=True)
if isinstance(node, yaml.SequenceNode):
return loader.construct_sequence(node, deep=True)
return loader.construct_scalar(node)
_ComposeLoader.add_multi_constructor("!", _construct_compose_tag)
def _read_yaml(path: str) -> dict: def _read_yaml(path: str) -> dict:
return yaml.safe_load((PROJECT_ROOT / path).read_text()) return yaml.load((PROJECT_ROOT / path).read_text(), Loader=_ComposeLoader)
async def _run_lifespan(app) -> None: async def _run_lifespan(app) -> None:
@@ -41,7 +58,9 @@ def _configure_database_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
def test_compose_uses_migration_job_before_app() -> None: def test_compose_uses_migration_job_before_app() -> None:
compose = _read_yaml("docker-compose.yml") compose = _read_yaml("docker-compose.yml")
override = _read_yaml("docker-compose.override.yml") # Local dev overrides live in docker-compose.dev.yml (explicitly layered;
# see README "Docker Compose"). It supplies build: . for local-source builds.
dev = _read_yaml("docker-compose.dev.yml")
migration_service = compose["services"]["migration"] migration_service = compose["services"]["migration"]
app_service = compose["services"]["app"] app_service = compose["services"]["app"]
@@ -49,8 +68,8 @@ def test_compose_uses_migration_job_before_app() -> None:
assert migration_service["command"] == ["python", "-m", "scripts.run_migrations"] assert migration_service["command"] == ["python", "-m", "scripts.run_migrations"]
assert migration_service["restart"] == "no" assert migration_service["restart"] == "no"
assert app_service["depends_on"]["migration"]["condition"] == "service_completed_successfully" assert app_service["depends_on"]["migration"]["condition"] == "service_completed_successfully"
assert override["services"]["migration"]["build"] == "." assert dev["services"]["migration"]["build"] == "."
assert override["services"]["app"]["build"] == "." assert dev["services"]["app"]["build"] == "."
def test_image_defaults_to_uvicorn_only() -> None: def test_image_defaults_to_uvicorn_only() -> None:
@@ -65,16 +84,25 @@ def test_image_defaults_to_uvicorn_only() -> None:
def test_dockerfile_copy_sources_exist() -> None: def test_dockerfile_copy_sources_exist() -> None:
"""Every path the Dockerfile COPYs from the build context must exist in the """Every path the Dockerfile COPYs *from the build context* must exist in
repo, so the image build cannot break on a stale COPY of a removed path the repo, so the image build cannot break on a stale COPY of a removed path
(e.g. the retired alembic_location / alembic_poo chains).""" (e.g. the retired alembic_location / alembic_poo chains).
COPY instructions that use --from=<stage> copy from a build stage, not from
the host build context, so their source paths are intentionally skipped here
(they would not correspond to repo paths)."""
dockerfile = (PROJECT_ROOT / "Dockerfile").read_text() dockerfile = (PROJECT_ROOT / "Dockerfile").read_text()
for raw_line in dockerfile.splitlines(): for raw_line in dockerfile.splitlines():
line = raw_line.strip() line = raw_line.strip()
if not line.startswith("COPY "): if not line.startswith("COPY "):
continue continue
# Drop the "COPY" keyword and any flags (e.g. --from=, --chown=). tokens = line.split()[1:]
tokens = [t for t in line.split()[1:] if not t.startswith("--")] # Skip inter-stage copies: --from=<stage> means the source is inside
# a build stage, not the host build context.
if any(t.startswith("--from=") for t in tokens):
continue
# Drop remaining flags (e.g. --chown=, --chmod=).
tokens = [t for t in tokens if not t.startswith("--")]
# COPY <src...> <dest>: the last token is the destination. # COPY <src...> <dest>: the last token is the destination.
for src in tokens[:-1]: for src in tokens[:-1]:
assert (PROJECT_ROOT / src).exists(), ( assert (PROJECT_ROOT / src).exists(), (
@@ -82,6 +110,57 @@ def test_dockerfile_copy_sources_exist() -> None:
) )
def test_dockerfile_multistage_frontend_build() -> None:
"""The Dockerfile must have a node frontend-build stage that builds the SPA,
and the runtime (python) stage must copy the dist from that stage.
The runtime stage must not include a node base image."""
dockerfile = (PROJECT_ROOT / "Dockerfile").read_text()
# 1. A named frontend-build stage using a node base image must exist.
assert "AS frontend-build" in dockerfile, (
"Dockerfile must have a 'AS frontend-build' node build stage"
)
node_stage_lines = [
ln.strip() for ln in dockerfile.splitlines()
if ln.strip().startswith("FROM") and "frontend-build" in ln
]
assert node_stage_lines, "No FROM line found that declares the frontend-build stage"
assert any("node" in ln.lower() for ln in node_stage_lines), (
"The frontend-build stage must use a node base image"
)
# 2. The frontend-build stage must run `npm run build`.
assert "npm run build" in dockerfile, (
"Dockerfile must run 'npm run build' in the frontend-build stage"
)
# 3. The runtime stage must COPY the dist from frontend-build into frontend/dist.
copy_from_lines = [
ln.strip() for ln in dockerfile.splitlines()
if ln.strip().startswith("COPY") and "--from=frontend-build" in ln
]
assert copy_from_lines, (
"Dockerfile must have a 'COPY --from=frontend-build' instruction in the runtime stage"
)
# The destination must land at (or under) frontend/dist so it matches SPA_DIST_DIR default.
assert any("frontend/dist" in ln for ln in copy_from_lines), (
"The COPY --from=frontend-build must target ./frontend/dist"
)
# 4. The runtime stage base image must be python, not node.
from_lines = [ln.strip() for ln in dockerfile.splitlines() if ln.strip().startswith("FROM")]
# All FROM lines except the frontend-build stage must use python.
runtime_from_lines = [ln for ln in from_lines if "frontend-build" not in ln]
assert runtime_from_lines, "No runtime FROM line found"
for ln in runtime_from_lines:
assert "python" in ln.lower(), (
f"Runtime stage base image must be python, got: {ln}"
)
assert "node" not in ln.lower(), (
f"Runtime stage must not use a node base image, got: {ln}"
)
def test_migration_runner_initializes_and_is_idempotent( def test_migration_runner_initializes_and_is_idempotent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
+3 -17
View File
@@ -1,5 +1,4 @@
from datetime import UTC, datetime from datetime import UTC, datetime
import re
import sqlite3 import sqlite3
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -17,25 +16,12 @@ def _make_session(database_url: str) -> Session:
return session_local() return session_local()
def _extract_csrf_token(html: str) -> str:
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert match is not None
return match.group(1)
def _login(client: TestClient) -> None: def _login(client: TestClient) -> None:
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
response = client.post( response = client.post(
"/login", "/api/auth/login",
data={ json={"username": "admin", "password": "test-password"},
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
) )
assert response.status_code == 303 assert response.status_code == 200
def test_public_ip_first_seen_persists_state_and_history(auth_database) -> None: def test_public_ip_first_seen_persists_state_and_history(auth_database) -> None:
+6 -181
View File
@@ -1,8 +1,10 @@
import re """SMTP service-layer unit tests.
import sqlite3
import smtplib
from fastapi.testclient import TestClient Jinja-based HTTP flow tests (POST /config, POST /config/smtp/test via form) were
removed in M2-T11 when the Jinja routes were deleted. HTTP-level SMTP test
endpoint coverage lives in test_api_config.py.
"""
import smtplib
from app.config import Settings from app.config import Settings
from app.services.email import ( from app.services.email import (
@@ -14,27 +16,6 @@ from app.services.email import (
) )
def _extract_csrf_token(html: str) -> str:
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert match is not None
return match.group(1)
def _login(client: TestClient) -> None:
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
response = client.post(
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
assert response.status_code == 303
def _smtp_settings(**overrides) -> Settings: def _smtp_settings(**overrides) -> Settings:
payload = { payload = {
"app_env": "development", "app_env": "development",
@@ -237,159 +218,3 @@ def test_send_public_ip_changed_email_contains_expected_english_content(monkeypa
assert "Current IP: 198.51.100.25" in sent["body"] assert "Current IP: 198.51.100.25" in sent["body"]
assert "Detected at: 2026-04-29 10:00:00 UTC" in sent["body"] assert "Detected at: 2026-04-29 10:00:00 UTC" in sent["body"]
assert "update the trusted IP manually" in sent["body"] assert "update the trusted IP manually" in sent["body"]
def test_config_update_does_not_clear_existing_smtp_password(
client: TestClient, test_database_urls
) -> None:
_login(client)
config_page = client.get("/config")
config_csrf_token = _extract_csrf_token(config_page.text)
response = client.post(
"/config",
data={
"csrf_token": config_csrf_token,
"APP_NAME": "SMTP Config Test",
"APP_ENV": "development",
"APP_DEBUG": "true",
"APP_HOSTNAME": "localhost:8000",
"SMTP_ENABLED": "true",
"SMTP_HOST": "smtp.example.com",
"SMTP_PORT": "587",
"SMTP_USERNAME": "smtp-user",
"SMTP_PASSWORD": "persist-me",
"SMTP_FROM_ADDRESS": "sender@example.com",
"SMTP_TO_ADDRESS": "recipient@example.com",
"SMTP_USE_STARTTLS": "true",
"AUTH_SESSION_COOKIE_NAME": "home_automation_session",
"AUTH_SESSION_TTL_HOURS": "12",
"AUTH_COOKIE_SECURE_OVERRIDE": "false",
"POO_WEBHOOK_ID": "",
"POO_SENSOR_ENTITY_NAME": "sensor.test_poo_status",
"POO_SENSOR_FRIENDLY_NAME": "Poo Status",
"TICKTICK_CLIENT_ID": "",
"TICKTICK_CLIENT_SECRET": "",
"TICKTICK_TOKEN": "",
"HOME_ASSISTANT_BASE_URL": "",
"HOME_ASSISTANT_AUTH_TOKEN": "",
"HOME_ASSISTANT_TIMEOUT_SECONDS": "1.0",
"HOME_ASSISTANT_ACTION_TASK_PROJECT_ID": "",
},
follow_redirects=False,
)
assert response.status_code == 303
config_page = client.get("/config")
config_csrf_token = _extract_csrf_token(config_page.text)
response = client.post(
"/config",
data={
"csrf_token": config_csrf_token,
"APP_NAME": "SMTP Config Updated",
"APP_ENV": "development",
"APP_DEBUG": "true",
"APP_HOSTNAME": "localhost:8000",
"SMTP_ENABLED": "true",
"SMTP_HOST": "smtp.example.com",
"SMTP_PORT": "587",
"SMTP_USERNAME": "smtp-user",
"SMTP_PASSWORD": "",
"SMTP_FROM_ADDRESS": "sender@example.com",
"SMTP_TO_ADDRESS": "recipient@example.com",
"SMTP_USE_STARTTLS": "true",
"AUTH_SESSION_COOKIE_NAME": "home_automation_session",
"AUTH_SESSION_TTL_HOURS": "12",
"AUTH_COOKIE_SECURE_OVERRIDE": "false",
"POO_WEBHOOK_ID": "",
"POO_SENSOR_ENTITY_NAME": "sensor.test_poo_status",
"POO_SENSOR_FRIENDLY_NAME": "Poo Status",
"TICKTICK_CLIENT_ID": "",
"TICKTICK_CLIENT_SECRET": "",
"TICKTICK_TOKEN": "",
"HOME_ASSISTANT_BASE_URL": "",
"HOME_ASSISTANT_AUTH_TOKEN": "",
"HOME_ASSISTANT_TIMEOUT_SECONDS": "1.0",
"HOME_ASSISTANT_ACTION_TASK_PROJECT_ID": "",
},
follow_redirects=False,
)
assert response.status_code == 303
conn = sqlite3.connect(test_database_urls["app_path"])
try:
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
finally:
conn.close()
assert rows["SMTP_PASSWORD"] == "persist-me"
assert rows["APP_NAME"] == "SMTP Config Updated"
def test_smtp_test_endpoint_requires_authentication(client: TestClient) -> None:
response = client.post("/config/smtp/test", data={"csrf_token": "ignored"}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_smtp_test_endpoint_success_and_failure_do_not_expose_password(
client: TestClient, monkeypatch
) -> None:
from app.api.routes import pages
_login(client)
config_page = client.get("/config")
csrf_token = _extract_csrf_token(config_page.text)
monkeypatch.setattr(pages, "send_smtp_test_email", lambda settings: None)
response = client.post("/config/smtp/test", data={"csrf_token": csrf_token}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/config?smtp_test=success"
follow_up = client.get(response.headers["location"])
assert follow_up.status_code == 200
assert "SMTP test email sent successfully." in follow_up.text
assert "super-secret-password" not in follow_up.text
monkeypatch.setattr(
pages,
"send_smtp_test_email",
lambda settings: (_ for _ in ()).throw(EmailDeliveryError("smtp auth failed for [redacted]")),
)
response = client.post("/config/smtp/test", data={"csrf_token": csrf_token}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/config?smtp_test=failed"
follow_up = client.get(response.headers["location"])
assert follow_up.status_code == 200
assert "SMTP test failed. Check saved SMTP settings and server reachability." in follow_up.text
assert "super-secret-password" not in follow_up.text
def test_config_page_renders_smtp_test_button_with_formaction(
client: TestClient, test_database_urls
) -> None:
_login(client)
conn = sqlite3.connect(test_database_urls["app_path"])
try:
conn.executemany(
"INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
[
("SMTP_ENABLED", "true"),
("SMTP_HOST", "smtp.example.com"),
("SMTP_PORT", "587"),
("SMTP_FROM_ADDRESS", "sender@example.com"),
("SMTP_TO_ADDRESS", "recipient@example.com"),
],
)
conn.commit()
finally:
conn.close()
response = client.get("/config")
assert response.status_code == 200
assert 'formaction="/config/smtp/test"' in response.text
+243
View File
@@ -0,0 +1,243 @@
"""Tests for M2-T11: SPA hosting + fallback behavior in app/main.py.
Uses SPA_DIST_DIR env var to point at a temporary directory containing a fake
index.html and an asset file, so tests are hermetic and don't depend on the
real frontend/dist build.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import get_settings
from app.db import reset_db_caches
from app.main import create_app
@pytest.fixture
def spa_dist(tmp_path: Path) -> Path:
"""Create a minimal fake SPA dist directory.
Layout:
tmp_path/
secret.txt OUTSIDE dist must never be served
fake_dist/
index.html
assets/
main.js
"""
# A secret file placed OUTSIDE the dist dir — used by traversal tests.
(tmp_path / "secret.txt").write_text("TOP_SECRET_SENTINEL", encoding="utf-8")
dist = tmp_path / "fake_dist"
dist.mkdir()
(dist / "index.html").write_text(
"<!DOCTYPE html><html><body>SPA INDEX</body></html>", encoding="utf-8"
)
assets = dist / "assets"
assets.mkdir()
(assets / "main.js").write_text("console.log('app');", encoding="utf-8")
return dist
@pytest.fixture
def spa_client(spa_dist: Path, auth_database, monkeypatch: pytest.MonkeyPatch) -> TestClient:
"""TestClient with a fresh app wired to the fake SPA dist."""
monkeypatch.setenv("SPA_DIST_DIR", str(spa_dist))
get_settings.cache_clear()
reset_db_caches()
app = create_app()
with TestClient(app) as client:
yield client
get_settings.cache_clear()
reset_db_caches()
# ---------------------------------------------------------------------------
# SPA fallback — client routes served as index.html
# ---------------------------------------------------------------------------
def test_spa_root_returns_index_html(spa_client: TestClient) -> None:
"""GET / returns the SPA index.html (200)."""
response = spa_client.get("/", follow_redirects=False)
assert response.status_code == 200
assert "SPA INDEX" in response.text
def test_spa_config_route_returns_index_html(spa_client: TestClient) -> None:
"""/config is a client-side route; the fallback must serve index.html."""
response = spa_client.get("/config", follow_redirects=False)
assert response.status_code == 200
assert "SPA INDEX" in response.text
def test_spa_records_route_returns_index_html(spa_client: TestClient) -> None:
"""/records is a client-side route; the fallback must serve index.html."""
response = spa_client.get("/records", follow_redirects=False)
assert response.status_code == 200
assert "SPA INDEX" in response.text
def test_spa_deep_link_returns_index_html(spa_client: TestClient) -> None:
"""/some/deep/path that doesn't exist on disk returns index.html (deep-link support)."""
response = spa_client.get("/some/deep/path", follow_redirects=False)
assert response.status_code == 200
assert "SPA INDEX" in response.text
# ---------------------------------------------------------------------------
# SPA asset serving
# ---------------------------------------------------------------------------
def test_spa_asset_is_served(spa_client: TestClient) -> None:
"""/assets/main.js must be served directly from the dist/assets directory."""
response = spa_client.get("/assets/main.js")
assert response.status_code == 200
assert "console.log" in response.text
# ---------------------------------------------------------------------------
# API routes not swallowed by fallback
# ---------------------------------------------------------------------------
def test_unauthenticated_api_session_returns_401_not_index(spa_client: TestClient) -> None:
"""/api/session without a session cookie must return 401, not index.html."""
response = spa_client.get("/api/session")
assert response.status_code == 401
assert "SPA INDEX" not in response.text
def test_unknown_api_path_returns_404_not_index(spa_client: TestClient) -> None:
"""/api/does-not-exist must return 404, not index.html."""
response = spa_client.get("/api/does-not-exist")
assert response.status_code == 404
assert "SPA INDEX" not in response.text
def test_api_typo_returns_404_not_index(spa_client: TestClient) -> None:
"""/api/typo returns 404 (the fallback must not serve index.html for /api/*)."""
response = spa_client.get("/api/typo")
assert response.status_code == 404
assert "SPA INDEX" not in response.text
# ---------------------------------------------------------------------------
# FastAPI built-in endpoints not swallowed by fallback
# ---------------------------------------------------------------------------
def test_openapi_json_is_served(spa_client: TestClient) -> None:
"""/openapi.json must be served by FastAPI, not the SPA fallback."""
response = spa_client.get("/openapi.json")
assert response.status_code == 200
body = response.json()
assert "openapi" in body
def test_docs_endpoint_is_served(spa_client: TestClient) -> None:
"""/docs must be served by FastAPI Swagger UI, not index.html."""
response = spa_client.get("/docs")
assert response.status_code == 200
assert "SPA INDEX" not in response.text
def test_status_endpoint_is_served(spa_client: TestClient) -> None:
"""/status must remain reachable and return JSON."""
response = spa_client.get("/status")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
# ---------------------------------------------------------------------------
# Path-traversal containment — security regression tests
# ---------------------------------------------------------------------------
def test_path_traversal_encoded_dotdot_slash_returns_index_not_secret(
spa_client: TestClient, spa_dist: Path
) -> None:
"""GET /..%2fsecret.txt must NOT return the secret file outside the dist dir.
Starlette URL-decodes {full_path:path} but does not normalise it, so an
encoded '../' can escape the dist root without the containment guard.
The guarded implementation resolves the candidate and checks is_relative_to;
a path that escapes the root falls back to index.html instead.
"""
response = spa_client.get("/..%2fsecret.txt", follow_redirects=False)
# Must not expose the secret content.
assert "TOP_SECRET_SENTINEL" not in response.text
# Should be a successful SPA index response (not a server error).
assert response.status_code == 200
assert "SPA INDEX" in response.text
def test_path_traversal_pct_encoded_dotdot_returns_index_not_secret(
spa_client: TestClient, spa_dist: Path
) -> None:
"""GET /%2e%2e%2fsecret.txt must NOT expose the file outside dist.
Covers the %2e%2e encoding variant of '..'.
"""
response = spa_client.get("/%2e%2e%2fsecret.txt", follow_redirects=False)
assert "TOP_SECRET_SENTINEL" not in response.text
assert response.status_code == 200
assert "SPA INDEX" in response.text
def test_path_traversal_nested_encoded_dotdot_returns_index_not_secret(
spa_client: TestClient, spa_dist: Path
) -> None:
"""GET /fake_dist/..%2f..%2fsecret.txt (deeper traversal) must not leak."""
response = spa_client.get("/fake_dist/..%2f..%2fsecret.txt", follow_redirects=False)
assert "TOP_SECRET_SENTINEL" not in response.text
assert response.status_code == 200
assert "SPA INDEX" in response.text
def test_legit_nested_asset_inside_dist_is_served(
spa_client: TestClient, spa_dist: Path
) -> None:
"""A real file inside the dist dir is still served correctly after the fix.
Place a nested asset directly inside dist (not under /assets) and confirm
the catch-all serves it.
"""
nested = spa_dist / "nested" / "chunk.js"
nested.parent.mkdir()
nested.write_text("// nested chunk", encoding="utf-8")
response = spa_client.get("/nested/chunk.js", follow_redirects=False)
assert response.status_code == 200
assert "nested chunk" in response.text
# ---------------------------------------------------------------------------
# SPA disabled when dist dir is missing
# ---------------------------------------------------------------------------
def test_spa_disabled_when_dist_missing(
tmp_path: Path, auth_database, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When SPA_DIST_DIR points to a non-existent directory, the app still starts
and API routes work normally the SPA fallback is simply absent."""
empty = tmp_path / "no_dist_here"
monkeypatch.setenv("SPA_DIST_DIR", str(empty))
get_settings.cache_clear()
reset_db_caches()
app = create_app()
with TestClient(app) as client:
response = client.get("/status")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
# API still returns 401 for unauthenticated access
api_response = client.get("/api/session")
assert api_response.status_code == 401
get_settings.cache_clear()
reset_db_caches()
+4 -18
View File
@@ -49,14 +49,6 @@ def _configured_settings(**overrides) -> Settings:
return Settings(_env_file=None, **payload) return Settings(_env_file=None, **payload)
def _extract_csrf_token(html: str) -> str:
import re
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert match is not None
return match.group(1)
def test_build_authorization_url_contains_expected_query(monkeypatch: pytest.MonkeyPatch) -> None: def test_build_authorization_url_contains_expected_query(monkeypatch: pytest.MonkeyPatch) -> None:
client = TickTickClient(settings=_configured_settings()) client = TickTickClient(settings=_configured_settings())
monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-123") monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-123")
@@ -263,17 +255,11 @@ def test_ticktick_auth_start_redirects_authenticated_user(
monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-redirect") monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-redirect")
with TestClient(create_app()) as client: with TestClient(create_app()) as client:
login_page = client.get("/login") resp = client.post(
csrf_token = _extract_csrf_token(login_page.text) "/api/auth/login",
client.post( json={"username": "admin", "password": "test-password"},
"/login",
data={
"username": "admin",
"password": "test-password",
"csrf_token": csrf_token,
},
follow_redirects=False,
) )
assert resp.status_code == 200, f"API login failed: {resp.status_code}"
response = client.get("/ticktick/auth/start", follow_redirects=False) response = client.get("/ticktick/auth/start", follow_redirects=False)