Compare commits
10 Commits
6cc6382515
...
da236643f2
| Author | SHA1 | Date | |
|---|---|---|---|
| da236643f2 | |||
| bd09523e94 | |||
| 53f1245d83 | |||
| 51f712f602 | |||
| f8b1e5fc71 | |||
| a9830c42d8 | |||
| 8aa7316b26 | |||
| 32d93bba2a | |||
| 0d988a9b28 | |||
| ef7ea6b971 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
## 前端 v2(React 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` header(SameSite=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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
services:
|
|
||||||
migration:
|
|
||||||
build: .
|
|
||||||
|
|
||||||
app:
|
|
||||||
build: .
|
|
||||||
@@ -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 模块
|
||||||
- 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象
|
- 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象
|
||||||
|
|
||||||
|
|||||||
@@ -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-T06(CRUD 来自 T04)
|
- **Status**: `done` · **Depends**: M2-T06(CRUD 来自 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
@@ -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 — 前端 v2(React SPA)
|
## M2 — 前端 v2(React 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 — 开放与移动端(远期试水)
|
||||||
|
|||||||
Generated
+88
-3
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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.
|
||||||
|
|||||||
Vendored
-365
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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:
|
||||||
|
'© <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:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
Vendored
+40
@@ -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 _
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
@@ -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
|
|
||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user