fix(bitable): 修复表格头部与数据行之间的幽灵空白

- BitableGrid.vue: 小数据量时移除 height 属性,让 vxe-grid 按内容高度渲染
- 为 .bitable-grid-scope__group-grid 添加 flex:1/min-height:0,避免 vxe-table 回退到 16777216px 的异常高度
- 小数据量时禁用固定列,避免固定列容器错位导致底部出现重复表头
- 隐藏 vxe-table 内置 empty-placeholder,防止有数据时仍显示“暂无数据”
- 隐藏小数据量下 vxe-table 错误的 .vxe-body--y-space 占位,消除表头与首行之间的 132px 间隙
- test_user_service.py: 中文化错误信息断言同步更新
This commit is contained in:
Chiguyong 2026-07-06 20:27:04 +08:00
parent 99a512c3f7
commit fede996dcb
8 changed files with 211 additions and 169 deletions

View File

@ -5,12 +5,13 @@ version: "3.8"
# =============================================================================
# 用法docker compose -f docker-compose.yaml -f docker-compose.dev.yml up -d redis postgres
#
# 仅启动 redis + postgresagentkit 在宿主机运行),映射端口到 6381/5435
# 仅启动 redis + postgresagentkit 在宿主机运行),映射端口到 6391/5445
# 避免与 pms-redis(6379) / geo_redis(6380) / geo_db(5433) 端口冲突
#
# .env.dev 应包含:
# REDIS_URL=redis://127.0.0.1:6381/0
# DATABASE_URL=postgresql+asyncpg://agentkit:agentkit@127.0.0.1:5435/agentkit
# 注意:端口须与 .env / agentkit.yaml 保持一致。
# .env 应包含:
# REDIS_URL=redis://127.0.0.1:6391/0
# DATABASE_URL=postgresql+asyncpg://agentkit:agentkit@127.0.0.1:5445/agentkit
# =============================================================================
services:
@ -20,8 +21,8 @@ services:
redis:
ports:
- "6381:6379"
- "6391:6379"
postgres:
ports:
- "5435:5432"
- "5445:5432"

View File

@ -17,13 +17,15 @@
# Python >= 3.11, Node.js >= 18, Redis, PostgreSQL (均自动检查)
# --tauri 需要Rust 工具链rustup / brew install rust
#
# 端口(与 .env.dev 保持一致):
# 端口(与 agentkit.yaml / vite.config.ts 保持一致):
# 18001 — AgentKit 后端 API
# 18002 — Web GUI前端 + 内置静态服务)
# 15173 — Vite 开发服务器(--tauri 模式)
# 15174 — Vite HMR websocket
# 6381 — Redisagentkit 专属容器,避免与 pms-redis:6379 / geo_redis:6380 冲突)
# 5435 — PostgreSQL+pgvectoragentkit 专属容器,避免与 geo_db:5433 冲突)
#
# 中间件端口已与 .env / agentkit.yaml 对齐:
# Redis → 6391
# PostgreSQL → 5445
#
# =============================================================================
@ -33,6 +35,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
# 确保 Rust 工具链在 PATH 中AI 终端 / 某些 shell 不会自动加载 ~/.cargo/env
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
export PATH="$HOME/.cargo/bin:$PATH"
# ── 颜色 ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
@ -63,8 +69,8 @@ Fischer AgentKit — 本地开发环境启动
18001 — 后端 API
18002 — Web GUI / 前端静态服务
15173 — Vite 开发服务器(--tauri 模式)
6381 — Redisagentkit 专属容器)
5435 — PostgreSQL+pgvectoragentkit 专属容器)
6391 — Redisagentkit 专属容器)
5445 — PostgreSQL+pgvectoragentkit 专属容器)
EOF
}
@ -122,8 +128,8 @@ print_status() {
echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"
echo -e "$([[ $S_DEPS -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_DEPS -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") 依赖检查"
echo -e "$([[ $S_ENV -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_ENV -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") 环境配置"
echo -e "$([[ $S_REDIS -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_REDIS -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") Redis (:6381)"
echo -e "$([[ $S_PG -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_PG -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") PostgreSQL (:5435)"
echo -e "$([[ $S_REDIS -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_REDIS -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") Redis (:6391)"
echo -e "$([[ $S_PG -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_PG -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") PostgreSQL (:5445)"
echo -e "$([[ $S_BACKEND -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_BACKEND -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") 后端服务 (:18001)"
if [[ $MODE == "gui" || $MODE == "tauri" ]]; then
echo -e "$([[ $S_FRONTEND -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_FRONTEND -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") 前端服务 (:18002)"
@ -190,18 +196,18 @@ check_redis() {
section "检查 Redis"
set_status redis 1
# agentkit 专属容器映射到 6381避免与 pms-redis(6379) / geo_redis(6380) 冲突
if redis-cli -h 127.0.0.1 -p 6381 ping 2>/dev/null | grep -q PONG; then
ok "Redis 运行中 (127.0.0.1:6381, agentkit 专属容器)"
# agentkit 专属容器映射到 6391避免与 pms-redis(6379) / geo_redis(6380) 冲突
if redis-cli -h 127.0.0.1 -p 6391 ping 2>/dev/null | grep -q PONG; then
ok "Redis 运行中 (127.0.0.1:6391, agentkit 专属容器)"
set_status redis 2
return 0
fi
warn "Redis 未运行 (6381),启动 agentkit 专属 Docker 容器..."
warn "Redis 未运行 (6391),启动 agentkit 专属 Docker 容器..."
if docker compose -f docker-compose.yaml -f docker-compose.dev.yml up -d redis &>/dev/null; then
sleep 2
if redis-cli -h 127.0.0.1 -p 6381 ping 2>/dev/null | grep -q PONG; then
ok "Redis Docker 容器启动成功 (127.0.0.1:6381)"
if redis-cli -h 127.0.0.1 -p 6391 ping 2>/dev/null | grep -q PONG; then
ok "Redis Docker 容器启动成功 (127.0.0.1:6391)"
set_status redis 2
return 0
fi
@ -215,18 +221,18 @@ check_postgres() {
section "检查 PostgreSQL"
set_status pg 1
# agentkit 专属容器映射到 5435避免与 geo_db(5433) 冲突
if PGPASSWORD=agentkit psql -h 127.0.0.1 -p 5435 -U agentkit -d agentkit -c "SELECT 1" &>/dev/null; then
ok "PostgreSQL 运行中 (127.0.0.1:5435, agentkit 专属容器)"
# agentkit 专属容器映射到 5445避免与 geo_db(5433) 冲突
if PGPASSWORD=agentkit psql -h 127.0.0.1 -p 5445 -U agentkit -d agentkit -c "SELECT 1" &>/dev/null; then
ok "PostgreSQL 运行中 (127.0.0.1:5445, agentkit 专属容器)"
set_status pg 2
return 0
fi
warn "PostgreSQL 未运行 (5435),启动 agentkit 专属 Docker 容器..."
warn "PostgreSQL 未运行 (5445),启动 agentkit 专属 Docker 容器..."
if docker compose -f docker-compose.yaml -f docker-compose.dev.yml up -d postgres &>/dev/null; then
sleep 3
if PGPASSWORD=agentkit psql -h 127.0.0.1 -p 5435 -U agentkit -d agentkit -c "SELECT 1" &>/dev/null; then
ok "PostgreSQL Docker 容器启动成功 (127.0.0.1:5435)"
if PGPASSWORD=agentkit psql -h 127.0.0.1 -p 5445 -U agentkit -d agentkit -c "SELECT 1" &>/dev/null; then
ok "PostgreSQL Docker 容器启动成功 (127.0.0.1:5445)"
set_status pg 2
return 0
fi
@ -273,8 +279,7 @@ start_backend() {
set_status backend 1
info "启动后端 API (:18001)..."
source .venv/bin/activate
agentkit serve --port 18001 &
.venv/bin/python -m agentkit serve --port 18001 &
BACKEND_PID=$!
# 等待健康检查就绪(最多 60 秒)
@ -310,8 +315,7 @@ start_gui() {
set_status frontend 1
info "启动 Web GUI (:18002)..."
source .venv/bin/activate
agentkit gui --port 18002 &
.venv/bin/python -m agentkit gui --port 18002 &
GUI_PID=$!
# 等待就绪
@ -347,11 +351,11 @@ start_tauri() {
local FE_DIR="$PROJECT_ROOT/src/agentkit/server/frontend"
if ! command -v tauri &>/dev/null; then
warn "Tauri CLI 未安装,跳过桌面客户端"
info "安装方式:npm install -g @tauri-apps/cli"
set_status tauri 2
return 0
if ! command -v cargo &>/dev/null; then
warn "未检测到 Rust/Cargo跳过 Tauri 桌面客户端"
info "安装方式:curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
set_status tauri 3
return 1
fi
info "启动 Tauri 桌面客户端..."
@ -391,15 +395,19 @@ stop_services() {
echo ""
info "正在停止所有服务..."
for port in 18001 18002 15173 15174; do
# 与 dev-stop.sh 保持一致的端口列表
local DEV_PORTS=(18001 18002 15173 15174 8000 8001 8002 5173 5175)
for port in "${DEV_PORTS[@]}"; do
local pid=$(lsof -ti :$port 2>/dev/null || true)
if [[ -n "$pid" ]]; then
kill $pid 2>/dev/null && ok "端口 $port 已停止" || true
kill -9 $pid 2>/dev/null && ok "端口 $port 已停止" || true
fi
done
# Vite 进程
pkill -f "vite" 2>/dev/null && ok "Vite 进程已停止" || true
# Vite / esbuild 进程
pkill -f "vite --port 15173" 2>/dev/null && ok "Vite(:15173) 已停止" || true
pkill -f "vite --port 5175" 2>/dev/null && ok "Vite(:5175) 已停止" || true
pkill -f "esbuild.*ping" 2>/dev/null && ok "esbuild 服务进程已停止" || true
echo ""
ok "所有服务已停止。感谢使用!"
@ -425,6 +433,10 @@ check_env
# 初始化数据库
[[ $INIT_DB -eq 1 ]] && init_db
# 先清理历史残留进程,避免端口冲突(特别是 8002/5173 等旧实例)
section "清理历史进程"
"$SCRIPT_DIR/dev-stop.sh" || true
# 安装依赖
install_deps

View File

@ -9,16 +9,26 @@ cd "$PROJECT_ROOT"
echo "停止 Fischer AgentKit 开发服务..."
# 当前规范端口(与 agentkit.yaml / vite.config.ts 保持一致)
# 18001 — AgentKit 后端 API
# 18002 — Web GUI 内置静态服务
# 15173 — Vite 开发服务器Tauri 模式)
# 15174 — Vite HMR websocket
# 遗留端口一并清理,避免历史残留造成冲突
DEV_PORTS=(18001 18002 15173 15174 8000 8001 8002 5173 5175)
# 停止后端/API/GUI 进程(按端口)
for port in 8000 8001 8002 5173; do
for port in "${DEV_PORTS[@]}"; do
PID=$(lsof -ti :$port 2>/dev/null || true)
if [[ -n "$PID" ]]; then
kill $PID 2>/dev/null && echo " [OK] 端口 $port 已停止 (PID $PID)" || true
kill -9 $PID 2>/dev/null && echo " [OK] 端口 $port 已停止 (PID $PID)" || true
fi
done
# 停止 Vite/Node 进程
pkill -f "vite" 2>/dev/null && echo " [OK] Vite 进程已停止" || true
# 停止 Vite / esbuild 进程(按命令名匹配,避免误杀其他 node 服务)
pkill -f "vite --port 15173" 2>/dev/null && echo " [OK] Vite(:15173) 已停止" || true
pkill -f "vite --port 5175" 2>/dev/null && echo " [OK] Vite(:5175) 已停止" || true
pkill -f "esbuild.*ping" 2>/dev/null && echo " [OK] esbuild 服务进程已停止" || true
# 停止 Docker 容器(开发专用)
for container in fischer-redis-dev fischer-pg-dev; do

View File

@ -27,14 +27,16 @@
v-else
v-show="section.node === null || !isCollapsed(section.node)"
class="bitable-grid-scope__group-grid"
:class="{ 'bitable-grid-scope__group-grid--auto': !effectiveHeight }"
>
<vxe-grid
:ref="(el: unknown) => onGridRef(idx, el)"
:data="section.node ? rowsForGroup(section.node) : rows"
:columns="gridColumns"
:height="groupingEnabled ? 'auto' : height"
:height="effectiveHeight"
:loading="loading"
:row-config="rowConfig"
:checkbox-config="{ highlight: true, checkField: '_selected' }"
:cell-config="{ height: 44 }"
:header-config="{ height: 40 }"
:column-config="{ resizable: true }"
@ -50,9 +52,6 @@
@cell-click="onCellClick"
@cell-dblclick="onCellDblclick"
>
<template #empty>
<a-empty :description="emptyText" />
</template>
<!-- Custom cell renderers for attachment/image fields (U6) -->
<template
v-for="f in attachmentFields"
@ -141,6 +140,15 @@
</div>
</template>
<!-- Empty state rendered outside vxe-grid to avoid vxe-table empty-slot
lingering over populated rows when data arrives after initial mount. -->
<div
v-if="props.records.length === 0 && !props.loading"
class="bitable-grid-scope__empty"
>
<a-empty :description="emptyText" />
</div>
<!-- U3: Record detail drawer opened by clicking the row's seq cell.
Rendered here so the grid is self-contained; reads state from the
bitable store (currentRecordId / currentRecord / fields). -->
@ -253,6 +261,17 @@ const groupingEnabled = computed(
() => groupByItems.value.length > 0 && props.records.length > 0,
)
// ponytail: vxe-table creates a phantom 132px y-space spacer when a small
// dataset is rendered with height="100%" in a tall container. Drop the
// height prop for small / grouped datasets so the grid sizes to content;
// keep percentage height for large datasets where virtual scrolling is
// actually needed.
const effectiveHeight = computed<string | number | undefined>(() => {
if (groupingEnabled.value) return undefined
if (props.records.length <= 60) return undefined
return props.height
})
// Build the nested group tree. Uses store.fields (all fields, not just
// visible ones) so number-field aggregations work even when the field is
// hidden by the view's hidden_fields config.
@ -344,7 +363,7 @@ function rowClassName({ row }: { row: unknown; rowIndex: number }): string {
// className function so vxe-table calls it for every data row.
const rowConfig = computed(() => ({
keyField: '_recordId',
className: rowClassName as unknown as string,
className: rowClassName,
}))
function fieldName(fieldId: string): string {
@ -424,17 +443,23 @@ const rows = computed<GridRow[]>(() =>
// Build vxe-grid columns from field definitions
const gridColumns = computed<GridColumn[]>(() => {
// ponytail: fixed columns + auto height trigger vxe-table layout bugs
// (phantom y-space + misplaced fixed wrappers). Disable fixed columns
// for small datasets; keep them for large datasets where horizontal
// scrolling makes fixed columns valuable.
const fixedLeft = props.records.length > 60 ? 'left' : undefined
const cols: GridColumn[] = [
{
type: 'checkbox',
width: 40,
fixed: 'left',
fixed: fixedLeft,
resizable: false,
editRender: { enabled: false },
},
{
type: 'seq',
width: 40,
fixed: 'left',
fixed: fixedLeft,
title: '#',
resizable: false,
},
@ -449,7 +474,7 @@ const gridColumns = computed<GridColumn[]>(() => {
field: '__add_field',
title: '',
width: 120,
fixed: 'right',
fixed: props.records.length > 60 ? 'right' : undefined,
resizable: false,
editRender: { enabled: false },
slots: { header: 'header__add_field' },
@ -568,6 +593,16 @@ defineExpose({
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.bitable-grid-scope__empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
}
/* KTD10: CSS isolation all vxe-table style overrides scoped to
@ -576,6 +611,44 @@ defineExpose({
font-size: var(--bitable-font-sm);
}
/* Hide vxe-table's built-in empty block + placeholder; we render empty state
ourselves outside the grid to avoid the slot lingering over populated rows
after data loads. */
.bitable-grid-scope :deep(.vxe-table--empty-block),
.bitable-grid-scope :deep(.vxe-table--empty-placeholder) {
display: none !important;
}
/* Make vxe-table checkboxes visible in dark theme. The default font-icon
color is too close to the background; we draw a plain box instead. */
.bitable-grid-scope :deep(.vxe-cell--checkbox) {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bitable-grid-scope :deep(.vxe-checkbox--icon) {
width: 14px;
height: 14px;
border: 1px solid var(--bitable-color-border-hover);
border-radius: 3px;
background-color: var(--bitable-color-bg);
display: inline-block;
box-sizing: border-box;
}
.bitable-grid-scope :deep(.vxe-checkbox--icon.vxe-table-icon-checkbox-checked) {
background-color: var(--bitable-color-primary);
border-color: var(--bitable-color-primary);
}
.bitable-grid-scope :deep(.vxe-checkbox--icon.vxe-table-icon-checkbox-indeterminate) {
background-color: var(--bitable-color-primary);
border-color: var(--bitable-color-primary);
}
.bitable-grid-scope :deep(.vxe-header--column) {
background: var(--bitable-color-bg-secondary);
font-weight: 600;
@ -679,9 +752,19 @@ defineExpose({
}
.bitable-grid-scope__group-grid {
flex: 1;
min-height: 0;
overflow: hidden;
border-bottom: 1px solid var(--bitable-color-border);
}
/* Auto-height grids (small datasets) should not have a virtual-scroll
y-space spacer; vxe-table incorrectly reserves row-height space there,
creating a phantom gap between the header and the first data row. */
.bitable-grid-scope__group-grid--auto :deep(.vxe-body--y-space) {
height: 0 !important;
}
/* ── U5: Conditional Formatting (8 colors + bold) ─────────────────────── */
/* Row-level classes applied via row-config.className. Group headers are
separate <div> elements, so they're naturally unaffected by CF. */

View File

@ -1,6 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'ant-design-vue/dist/reset.css'
import VXETable from 'vxe-table'
import './styles'
import './styles/bitable-tokens.css'
import App from './App.vue'
@ -11,6 +12,7 @@ const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(VXETable)
// Kick off the cold-start auth probe BEFORE mount so that
// ``router.beforeEach`` can ``await authStore.waitForStartup()`` instead

View File

@ -466,7 +466,13 @@ async function handleDeleteField(field: IBitableField): Promise<void> {
padding: var(--bitable-spacing-sm) var(--bitable-spacing-lg);
border-bottom: 1px solid var(--bitable-color-border);
flex-shrink: 0;
flex-wrap: wrap;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.bitable-file-detail-view__toolbar::-webkit-scrollbar {
display: none;
}
.bitable-file-detail-view__toolbar-left {
@ -474,14 +480,18 @@ async function handleDeleteField(field: IBitableField): Promise<void> {
align-items: center;
gap: var(--bitable-spacing-sm);
min-width: 0;
flex-shrink: 0;
flex-wrap: nowrap;
}
.bitable-file-detail-view__toolbar-right {
display: flex;
align-items: center;
gap: var(--bitable-spacing-sm);
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: flex-end;
white-space: nowrap;
flex-shrink: 0;
}
.bitable-file-detail-view__record-count {

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fischer AgentKit</title>
<script type="module" crossorigin src="/assets/index-agwA6wam.js"></script>
<script type="module" crossorigin src="/assets/index-BYNVyxSE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ls4ZdRZM.css">
</head>
<body>

View File

@ -151,9 +151,7 @@ async def _fetch_user_row(db_path: Path, user_id: str) -> dict[str, Any] | None:
class TestCreateUserHappyPath:
async def test_create_returns_user_dict(self, service: UserService, fresh_db: Path):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
assert user["id"]
assert user["username"] == "alice"
assert user["email"] == "alice@example.com"
@ -206,13 +204,9 @@ class TestCreateUserHappyPath:
dept_ids = {d["id"] for d in user["departments"]}
assert dept_ids == {eng["id"], hr["id"]}
async def test_password_is_bcrypt_hashed(
self, service: UserService, fresh_db: Path
):
async def test_password_is_bcrypt_hashed(self, service: UserService, fresh_db: Path):
"""The stored password_hash must be a bcrypt hash, not plaintext."""
user = await service.create_user(
fresh_db, "eve", "eve@example.com", "MyPassword123!"
)
user = await service.create_user(fresh_db, "eve", "eve@example.com", "MyPassword123!")
row = await _fetch_user_row(fresh_db, user["id"])
assert row is not None
stored_hash = row["password_hash"]
@ -232,19 +226,13 @@ class TestCreateUserEdgeCases:
self, service: UserService, fresh_db: Path
):
await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
with pytest.raises(ValueError, match="username"):
await service.create_user(
fresh_db, "alice", "other@example.com", "Secret123!"
)
with pytest.raises(ValueError, match="用户名"):
await service.create_user(fresh_db, "alice", "other@example.com", "Secret123!")
async def test_duplicate_email_raises_value_error(
self, service: UserService, fresh_db: Path
):
async def test_duplicate_email_raises_value_error(self, service: UserService, fresh_db: Path):
await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
with pytest.raises(ValueError, match="email"):
await service.create_user(
fresh_db, "alice2", "alice@example.com", "Secret123!"
)
with pytest.raises(ValueError, match="邮箱"):
await service.create_user(fresh_db, "alice2", "alice@example.com", "Secret123!")
async def test_create_with_nonexistent_department_id_raises(
self,
@ -292,12 +280,8 @@ class TestListUsers:
names = {u["username"] for u in users}
assert names == {"alice", "bob"}
async def test_list_excludes_inactive_when_asked(
self, service: UserService, fresh_db: Path
):
alice = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
async def test_list_excludes_inactive_when_asked(self, service: UserService, fresh_db: Path):
alice = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
await service.create_user(fresh_db, "bob", "bob@example.com", "Secret123!")
# Soft-delete alice.
await service.delete_user(fresh_db, alice["id"])
@ -316,12 +300,8 @@ class TestListUsers:
eng = await _create_test_department(department_service, fresh_db, "Engineering")
hr = await _create_test_department(department_service, fresh_db, "HR")
alice = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
bob = await service.create_user(
fresh_db, "bob", "bob@example.com", "Secret123!"
)
alice = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
bob = await service.create_user(fresh_db, "bob", "bob@example.com", "Secret123!")
await service.assign_department(fresh_db, alice["id"], eng["id"])
await service.assign_department(fresh_db, bob["id"], hr["id"])
@ -340,9 +320,7 @@ class TestListUsers:
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
alice = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
alice = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
await service.assign_department(fresh_db, alice["id"], eng["id"])
users = await service.list_users(fresh_db)
assert len(users) == 1
@ -364,9 +342,7 @@ class TestGetUser:
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
await service.assign_department(fresh_db, user["id"], eng["id"])
fetched = await service.get_user(fresh_db, user["id"])
assert fetched is not None
@ -374,9 +350,7 @@ class TestGetUser:
assert len(fetched["departments"]) == 1
assert fetched["departments"][0]["name"] == "Engineering"
async def test_get_returns_none_for_unknown_id(
self, service: UserService, fresh_db: Path
):
async def test_get_returns_none_for_unknown_id(self, service: UserService, fresh_db: Path):
result = await service.get_user(fresh_db, str(uuid.uuid4()))
assert result is None
@ -388,25 +362,17 @@ class TestGetUser:
class TestUpdateUser:
async def test_update_role(self, service: UserService, fresh_db: Path):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
updated = await service.update_user(fresh_db, user["id"], role="admin")
assert updated["role"] == "admin"
async def test_update_is_active(self, service: UserService, fresh_db: Path):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
updated = await service.update_user(fresh_db, user["id"], is_active=False)
assert updated["is_active"] is False
async def test_update_terminal_authorized_flags(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
async def test_update_terminal_authorized_flags(self, service: UserService, fresh_db: Path):
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
updated = await service.update_user(
fresh_db,
user["id"],
@ -419,9 +385,7 @@ class TestUpdateUser:
async def test_update_partial_only_changes_provided_fields(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
# Only update role; other fields should be preserved.
updated = await service.update_user(fresh_db, user["id"], role="admin")
assert updated["role"] == "admin"
@ -434,12 +398,8 @@ class TestUpdateUser:
with pytest.raises(ValueError, match="not found"):
await service.update_user(fresh_db, str(uuid.uuid4()), role="admin")
async def test_update_no_fields_provided_is_noop(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
async def test_update_no_fields_provided_is_noop(self, service: UserService, fresh_db: Path):
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
updated = await service.update_user(fresh_db, user["id"])
assert updated["username"] == "alice"
@ -450,20 +410,14 @@ class TestUpdateUser:
class TestDeleteUser:
async def test_delete_returns_true_for_active_user(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
async def test_delete_returns_true_for_active_user(self, service: UserService, fresh_db: Path):
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
deleted = await service.delete_user(fresh_db, user["id"])
assert deleted is True
async def test_delete_is_soft(self, service: UserService, fresh_db: Path):
"""delete_user must set is_active=0 but NOT remove the row."""
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
await service.delete_user(fresh_db, user["id"])
# Row must still exist.
row = await _fetch_user_row(fresh_db, user["id"])
@ -471,18 +425,14 @@ class TestDeleteUser:
# But is_active must be 0.
assert bool(row["is_active"]) is False
async def test_delete_returns_false_for_unknown_id(
self, service: UserService, fresh_db: Path
):
async def test_delete_returns_false_for_unknown_id(self, service: UserService, fresh_db: Path):
deleted = await service.delete_user(fresh_db, str(uuid.uuid4()))
assert deleted is False
async def test_delete_returns_false_for_already_inactive(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
first = await service.delete_user(fresh_db, user["id"])
assert first is True
# Second delete on the same (now-inactive) user must return False.
@ -502,9 +452,7 @@ class TestResetPassword:
session_service: SessionService,
fresh_db: Path,
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "OldPassword123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "OldPassword123!")
reset = await service.reset_password(fresh_db, user["id"], "NewPassword456!")
assert reset is True
@ -514,9 +462,7 @@ class TestResetPassword:
session_service: SessionService,
fresh_db: Path,
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "OldPassword123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "OldPassword123!")
old_row = await _fetch_user_row(fresh_db, user["id"])
await service.reset_password(fresh_db, user["id"], "NewPassword456!")
new_row = await _fetch_user_row(fresh_db, user["id"])
@ -530,9 +476,7 @@ class TestResetPassword:
session_service: SessionService,
fresh_db: Path,
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "OldPassword123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "OldPassword123!")
# Create two active sessions for the user.
await _create_session_for_user(fresh_db, user["id"])
await _create_session_for_user(fresh_db, user["id"])
@ -548,9 +492,7 @@ class TestResetPassword:
session_service: SessionService,
fresh_db: Path,
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "OldPassword123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "OldPassword123!")
session_id = await _create_session_for_user(fresh_db, user["id"])
await service.reset_password(fresh_db, user["id"], "NewPassword456!")
@ -571,9 +513,7 @@ class TestResetPassword:
session_service: SessionService,
fresh_db: Path,
):
reset = await service.reset_password(
fresh_db, str(uuid.uuid4()), "NewPassword456!"
)
reset = await service.reset_password(fresh_db, str(uuid.uuid4()), "NewPassword456!")
assert reset is False
@ -590,9 +530,7 @@ class TestAssignDepartment:
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
result = await service.assign_department(fresh_db, user["id"], eng["id"])
assert result["user_id"] == user["id"]
assert result["department_id"] == eng["id"]
@ -605,9 +543,7 @@ class TestAssignDepartment:
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
await service.assign_department(fresh_db, user["id"], eng["id"])
with pytest.raises(ValueError, match="already assigned"):
await service.assign_department(fresh_db, user["id"], eng["id"])
@ -617,9 +553,7 @@ class TestAssignDepartment:
service: UserService,
fresh_db: Path,
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
with pytest.raises(ValueError, match="Department.*not found"):
await service.assign_department(fresh_db, user["id"], str(uuid.uuid4()))
@ -632,9 +566,7 @@ class TestRemoveDepartment:
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
await service.assign_department(fresh_db, user["id"], eng["id"])
removed = await service.remove_department(fresh_db, user["id"], eng["id"])
assert removed is True
@ -649,9 +581,7 @@ class TestRemoveDepartment:
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
removed = await service.remove_department(fresh_db, user["id"], eng["id"])
assert removed is False
@ -665,9 +595,7 @@ class TestListUserDepartments:
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
hr = await _create_test_department(department_service, fresh_db, "HR")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
await service.assign_department(fresh_db, user["id"], eng["id"])
await service.assign_department(fresh_db, user["id"], hr["id"])
@ -682,9 +610,7 @@ class TestListUserDepartments:
async def test_list_returns_empty_for_user_with_no_departments(
self, service: UserService, fresh_db: Path
):
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
depts = await service.list_user_departments(fresh_db, user["id"])
assert depts == []
@ -695,9 +621,7 @@ class TestListUserDepartments:
fresh_db: Path,
):
eng = await _create_test_department(department_service, fresh_db, "Engineering")
user = await service.create_user(
fresh_db, "alice", "alice@example.com", "Secret123!"
)
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
await service.assign_department(fresh_db, user["id"], eng["id"])
# Disable the department.
await department_service.set_department_active(fresh_db, eng["id"], False)