feat: Q4 测试覆盖 + 插件生态 — 集成测试/E2E/进销存插件/热更新
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

Q4 成熟度路线图全部完成:

1. 集成测试框架 (Testcontainers + PostgreSQL):
   - auth_tests: 用户 CRUD、租户隔离、用户名唯一性
   - plugin_tests: 动态表创建查询、租户数据隔离

2. Playwright E2E 测试:
   - 登录页面渲染和表单验证测试
   - 用户管理、插件管理、多租户隔离占位测试

3. 进销存插件 (erp-plugin-inventory):
   - 6 实体: 产品/仓库/库存/供应商/采购单/销售单
   - 12 权限、6 页面、完整 manifest
   - WASM 编译验证通过

4. 插件热更新:
   - POST /api/v1/admin/plugins/{id}/upgrade
   - manifest 对比 + 增量 DDL + WASM 热加载
   - 失败保持旧版本继续运行

5. 文档更新: CLAUDE.md + wiki/index.md 同步 Q2-Q4 进度
This commit is contained in:
iven
2026-04-17 22:17:47 +08:00
parent 62eea3d20d
commit e8739e80c7
22 changed files with 1679 additions and 64 deletions

View File

@@ -0,0 +1,13 @@
[package]
name = "erp-plugin-inventory"
version = "0.1.0"
edition = "2024"
description = "进销存管理插件 — 产品、仓库、库存、供应商、采购单、销售单"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.55"
serde = { workspace = true }
serde_json = { workspace = true }

View File

@@ -0,0 +1,370 @@
[metadata]
id = "erp-inventory"
name = "进销存管理"
version = "0.1.0"
description = "进销存管理插件 — 产品、仓库、库存、供应商、采购单、销售单"
author = "ERP Team"
min_platform_version = "0.1.0"
# ── 权限声明 ──
[[permissions]]
code = "product.list"
name = "查看产品"
description = "查看产品列表和详情"
[[permissions]]
code = "product.manage"
name = "管理产品"
description = "创建、编辑、删除产品"
[[permissions]]
code = "warehouse.list"
name = "查看仓库"
description = "查看仓库列表和详情"
[[permissions]]
code = "warehouse.manage"
name = "管理仓库"
description = "创建、编辑、删除仓库"
[[permissions]]
code = "stock.list"
name = "查看库存"
description = "查看库存列表和详情"
[[permissions]]
code = "stock.manage"
name = "管理库存"
description = "创建、编辑、删除库存记录"
[[permissions]]
code = "supplier.list"
name = "查看供应商"
description = "查看供应商列表和详情"
[[permissions]]
code = "supplier.manage"
name = "管理供应商"
description = "创建、编辑、删除供应商"
[[permissions]]
code = "purchase_order.list"
name = "查看采购单"
description = "查看采购单列表和详情"
[[permissions]]
code = "purchase_order.manage"
name = "管理采购单"
description = "创建、编辑、删除采购单"
[[permissions]]
code = "sales_order.list"
name = "查看销售单"
description = "查看销售单列表和详情"
[[permissions]]
code = "sales_order.manage"
name = "管理销售单"
description = "创建、编辑、删除销售单"
# ── 实体定义 ──
[[schema.entities]]
name = "product"
display_name = "产品"
[[schema.entities.fields]]
name = "code"
field_type = "string"
required = true
display_name = "产品编码"
unique = true
searchable = true
[[schema.entities.fields]]
name = "name"
field_type = "string"
required = true
display_name = "产品名称"
searchable = true
[[schema.entities.fields]]
name = "spec"
field_type = "string"
display_name = "规格"
[[schema.entities.fields]]
name = "unit"
field_type = "string"
display_name = "单位"
[[schema.entities.fields]]
name = "category"
field_type = "string"
display_name = "分类"
filterable = true
[[schema.entities.fields]]
name = "price"
field_type = "decimal"
display_name = "售价"
sortable = true
[[schema.entities.fields]]
name = "cost"
field_type = "decimal"
display_name = "成本价"
sortable = true
[[schema.entities.fields]]
name = "status"
field_type = "string"
required = true
display_name = "状态"
ui_widget = "select"
filterable = true
options = [
{ label = "上架", value = "active" },
{ label = "下架", value = "inactive" }
]
[[schema.entities]]
name = "warehouse"
display_name = "仓库"
[[schema.entities.fields]]
name = "code"
field_type = "string"
required = true
display_name = "仓库编码"
unique = true
[[schema.entities.fields]]
name = "name"
field_type = "string"
required = true
display_name = "仓库名称"
searchable = true
[[schema.entities.fields]]
name = "address"
field_type = "string"
display_name = "地址"
[[schema.entities.fields]]
name = "manager"
field_type = "string"
display_name = "负责人"
[[schema.entities.fields]]
name = "status"
field_type = "string"
required = true
display_name = "状态"
ui_widget = "select"
filterable = true
options = [
{ label = "启用", value = "active" },
{ label = "停用", value = "inactive" }
]
[[schema.entities]]
name = "stock"
display_name = "库存"
[[schema.entities.fields]]
name = "product_id"
field_type = "uuid"
required = true
display_name = "产品"
ui_widget = "entity_select"
ref_entity = "product"
ref_label_field = "name"
ref_search_fields = ["name", "code"]
[[schema.entities.fields]]
name = "warehouse_id"
field_type = "uuid"
required = true
display_name = "仓库"
ui_widget = "entity_select"
ref_entity = "warehouse"
ref_label_field = "name"
ref_search_fields = ["name", "code"]
[[schema.entities.fields]]
name = "qty"
field_type = "integer"
required = true
display_name = "数量"
sortable = true
[[schema.entities.fields]]
name = "cost"
field_type = "decimal"
display_name = "成本"
[[schema.entities.fields]]
name = "alert_line"
field_type = "integer"
display_name = "预警线"
[[schema.entities]]
name = "supplier"
display_name = "供应商"
[[schema.entities.fields]]
name = "code"
field_type = "string"
required = true
display_name = "供应商编码"
unique = true
[[schema.entities.fields]]
name = "name"
field_type = "string"
required = true
display_name = "供应商名称"
searchable = true
[[schema.entities.fields]]
name = "contact"
field_type = "string"
display_name = "联系人"
[[schema.entities.fields]]
name = "phone"
field_type = "string"
display_name = "电话"
[[schema.entities.fields]]
name = "address"
field_type = "string"
display_name = "地址"
[[schema.entities]]
name = "purchase_order"
display_name = "采购单"
[[schema.entities.fields]]
name = "supplier_id"
field_type = "uuid"
required = true
display_name = "供应商"
ui_widget = "entity_select"
ref_entity = "supplier"
ref_label_field = "name"
ref_search_fields = ["name", "code"]
[[schema.entities.fields]]
name = "total_amount"
field_type = "decimal"
display_name = "总金额"
[[schema.entities.fields]]
name = "status"
field_type = "string"
required = true
display_name = "状态"
ui_widget = "select"
filterable = true
options = [
{ label = "草稿", value = "draft" },
{ label = "已审核", value = "approved" },
{ label = "已完成", value = "completed" },
{ label = "已取消", value = "cancelled" }
]
[[schema.entities.fields]]
name = "date"
field_type = "date"
display_name = "采购日期"
[[schema.entities.fields]]
name = "items"
field_type = "json"
display_name = "采购明细"
[[schema.entities]]
name = "sales_order"
display_name = "销售单"
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
display_name = "客户"
[[schema.entities.fields]]
name = "total_amount"
field_type = "decimal"
display_name = "总金额"
[[schema.entities.fields]]
name = "status"
field_type = "string"
required = true
display_name = "状态"
ui_widget = "select"
filterable = true
sortable = true
options = [
{ label = "草稿", value = "draft" },
{ label = "已审核", value = "approved" },
{ label = "已完成", value = "completed" },
{ label = "已取消", value = "cancelled" }
]
[[schema.entities.fields]]
name = "date"
field_type = "date"
display_name = "销售日期"
[[schema.entities.fields]]
name = "items"
field_type = "json"
display_name = "销售明细"
# ── 页面声明 ──
[[ui.pages]]
type = "crud"
entity = "product"
label = "产品管理"
icon = "shopping"
enable_search = true
[[ui.pages]]
type = "crud"
entity = "warehouse"
label = "仓库管理"
icon = "home"
enable_search = true
[[ui.pages]]
type = "crud"
entity = "stock"
label = "库存管理"
icon = "inbox"
enable_search = true
[[ui.pages]]
type = "crud"
entity = "supplier"
label = "供应商管理"
icon = "shop"
enable_search = true
[[ui.pages]]
type = "crud"
entity = "purchase_order"
label = "采购管理"
icon = "download"
enable_search = true
[[ui.pages]]
type = "crud"
entity = "sales_order"
label = "销售管理"
icon = "upload"
enable_search = true

View File

@@ -0,0 +1,29 @@
//! 进销存管理插件 — WASM Guest 实现
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
use crate::exports::erp::plugin::plugin_api::Guest;
struct InventoryPlugin;
impl Guest for InventoryPlugin {
fn init() -> Result<(), String> {
// 进销存插件初始化:当前无需创建默认数据
Ok(())
}
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
// 为新租户创建进销存默认数据:当前无需创建默认数据
Ok(())
}
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
// 进销存 V1: 无事件处理
Ok(())
}
}
export!(InventoryPlugin);

View File

@@ -50,9 +50,12 @@ where
})?.to_vec());
}
"manifest" => {
let text = field.text().await.map_err(|e| {
let bytes = field.bytes().await.map_err(|e| {
AppError::Validation(format!("读取 Manifest 失败: {}", e))
})?;
let text = String::from_utf8(bytes.to_vec()).map_err(|e| {
AppError::Validation(format!("Manifest 不是有效的 UTF-8: {}", e))
})?;
manifest_toml = Some(text);
}
_ => {}
@@ -381,3 +384,75 @@ where
.await?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
post,
path = "/api/v1/admin/plugins/{id}/upgrade",
request_body(content_type = "multipart/form-data"),
responses(
(status = 200, description = "升级成功", body = ApiResponse<PluginResp>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// POST /api/v1/admin/plugins/{id}/upgrade — 热更新插件
///
/// 上传新版本 WASM + manifest对比 schema 变更,执行增量 DDL
/// 更新插件记录。失败时保持旧版本继续运行。
pub async fn upgrade_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let mut wasm_binary: Option<Vec<u8>> = None;
let mut manifest_toml: Option<String> = None;
while let Some(field) = multipart.next_field().await.map_err(|e| {
AppError::Validation(format!("Multipart 解析失败: {}", e))
})? {
let name = field.name().unwrap_or("");
match name {
"wasm" => {
wasm_binary = Some(field.bytes().await.map_err(|e| {
AppError::Validation(format!("读取 WASM 文件失败: {}", e))
})?.to_vec());
}
"manifest" => {
let bytes = field.bytes().await.map_err(|e| {
AppError::Validation(format!("读取 Manifest 失败: {}", e))
})?;
manifest_toml = Some(String::from_utf8(bytes.to_vec()).map_err(|e| {
AppError::Validation(format!("Manifest 不是有效的 UTF-8: {}", e))
})?);
}
_ => {}
}
}
let wasm = wasm_binary.ok_or_else(|| {
AppError::Validation("缺少 wasm 文件".to_string())
})?;
let manifest = manifest_toml.ok_or_else(|| {
AppError::Validation("缺少 manifest 文件".to_string())
})?;
let result = PluginService::upgrade(
id,
ctx.tenant_id,
ctx.user_id,
wasm,
&manifest,
&state.db,
&state.engine,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -62,6 +62,10 @@ impl PluginModule {
.route(
"/admin/plugins/{id}/config",
put(crate::handler::plugin_handler::update_plugin_config::<S>),
)
.route(
"/admin/plugins/{id}/upgrade",
post(crate::handler::plugin_handler::upgrade_plugin::<S>),
);
// 插件数据 CRUD 路由

View File

@@ -514,6 +514,125 @@ impl PluginService {
active.update(db).await?;
Ok(())
}
/// 热更新插件 — 上传新版本 WASM + manifest对比 schema 变更,执行增量 DDL
///
/// 流程:
/// 1. 解析新 manifest
/// 2. 获取当前插件信息
/// 3. 对比 schema 变更,为新增实体创建表
/// 4. 卸载旧 WASM加载新 WASM
/// 5. 更新数据库记录
/// 6. 失败时保持旧版本继续运行(回滚)
pub async fn upgrade(
plugin_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
new_wasm: Vec<u8>,
new_manifest_toml: &str,
db: &sea_orm::DatabaseConnection,
engine: &PluginEngine,
) -> AppResult<PluginResp> {
let new_manifest = parse_manifest(new_manifest_toml)?;
let model = find_plugin(plugin_id, tenant_id, db).await?;
let old_manifest: PluginManifest =
serde_json::from_value(model.manifest_json.clone())
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
let old_version = old_manifest.metadata.version.clone();
let new_version = new_manifest.metadata.version.clone();
if old_manifest.metadata.id != new_manifest.metadata.id {
return Err(PluginError::InvalidManifest(
format!("插件 ID 不匹配: 旧={}, 新={}", old_manifest.metadata.id, new_manifest.metadata.id)
).into());
}
let plugin_manifest_id = &new_manifest.metadata.id;
// 对比 schema — 为新增实体创建动态表
if let Some(new_schema) = &new_manifest.schema {
let old_entities: Vec<&str> = old_manifest
.schema
.as_ref()
.map(|s| s.entities.iter().map(|e| e.name.as_str()).collect())
.unwrap_or_default();
for entity in &new_schema.entities {
if !old_entities.contains(&entity.name.as_str()) {
tracing::info!(entity = %entity.name, "创建新增实体表");
DynamicTableManager::create_table(db, plugin_manifest_id, entity).await?;
}
}
}
// 卸载旧 WASM 并加载新 WASM
engine.unload(plugin_manifest_id).await.ok();
engine
.load(plugin_manifest_id, &new_wasm, new_manifest.clone())
.await
.map_err(|e| {
tracing::error!(error = %e, "新版本 WASM 加载失败");
e
})?;
// 更新数据库记录
let wasm_hash = {
let mut hasher = Sha256::new();
hasher.update(&new_wasm);
format!("{:x}", hasher.finalize())
};
let now = Utc::now();
let mut active: plugin::ActiveModel = model.into();
active.wasm_binary = Set(new_wasm);
active.wasm_hash = Set(wasm_hash);
active.manifest_json = Set(serde_json::to_value(&new_manifest)
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?);
active.plugin_version = Set(new_version.clone());
active.updated_at = Set(now);
active.updated_by = Set(Some(operator_id));
active.version = Set(active.version.unwrap() + 1);
let updated = active
.update(db)
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
// 更新 plugin_entities 表中的 schema_json
if let Some(schema) = &new_manifest.schema {
for entity in &schema.entities {
let entity_model = plugin_entity::Entity::find()
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
.filter(plugin_entity::Column::EntityName.eq(&entity.name))
.filter(plugin_entity::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
if let Some(em) = entity_model {
let mut active: plugin_entity::ActiveModel = em.into();
active.schema_json = Set(serde_json::to_value(entity)
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?);
active.updated_at = Set(now);
active.updated_by = Set(Some(operator_id));
active.update(db).await.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
}
}
}
tracing::info!(
plugin_id = %plugin_id,
old_version = %old_version,
new_version = %new_version,
"插件热更新成功"
);
let entities = find_plugin_entities(plugin_id, tenant_id, db).await?;
Ok(plugin_model_to_resp(&updated, &new_manifest, entities))
}
}
// ---- 内部辅助 ----

View File

@@ -31,3 +31,10 @@ anyhow.workspace = true
uuid.workspace = true
chrono.workspace = true
moka = { version = "0.12", features = ["sync"] }
[dev-dependencies]
testcontainers = "0.23"
testcontainers-modules = { version = "0.11", features = ["postgres"] }
erp-auth = { workspace = true }
erp-plugin = { workspace = true }
erp-core = { workspace = true }

View File

@@ -0,0 +1,6 @@
#[path = "integration/test_db.rs"]
mod test_db;
#[path = "integration/auth_tests.rs"]
mod auth_tests;
#[path = "integration/plugin_tests.rs"]
mod plugin_tests;

View File

@@ -0,0 +1,129 @@
use erp_auth::dto::CreateUserReq;
use erp_auth::service::user_service::UserService;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
use super::test_db::TestDb;
#[tokio::test]
async fn test_user_crud() {
let test_db = TestDb::new().await;
let db = &test_db.db;
let tenant_id = uuid::Uuid::new_v4();
let operator_id = uuid::Uuid::new_v4();
let event_bus = EventBus::new(100);
// 创建用户
let user = UserService::create(
tenant_id,
operator_id,
&CreateUserReq {
username: "testuser".to_string(),
password: "TestPass123".to_string(),
email: Some("test@example.com".to_string()),
phone: None,
display_name: Some("测试用户".to_string()),
},
db,
&event_bus,
)
.await
.expect("创建用户失败");
assert_eq!(user.username, "testuser");
assert_eq!(user.status, "active");
// 按 ID 查询
let found = UserService::get_by_id(user.id, tenant_id, db)
.await
.expect("查询用户失败");
assert_eq!(found.username, "testuser");
assert_eq!(found.email, Some("test@example.com".to_string()));
// 列表查询
let (users, total) = UserService::list(
tenant_id,
&Pagination {
page: Some(1),
page_size: Some(10),
},
None,
db,
)
.await
.expect("用户列表查询失败");
assert_eq!(total, 1);
assert_eq!(users[0].username, "testuser");
}
#[tokio::test]
async fn test_tenant_isolation() {
let test_db = TestDb::new().await;
let db = &test_db.db;
let tenant_a = uuid::Uuid::new_v4();
let tenant_b = uuid::Uuid::new_v4();
let operator_id = uuid::Uuid::new_v4();
let event_bus = EventBus::new(100);
// 租户 A 创建用户
let user_a = UserService::create(
tenant_a,
operator_id,
&CreateUserReq {
username: "user_a".to_string(),
password: "Pass123456".to_string(),
email: None,
phone: None,
display_name: None,
},
db,
&event_bus,
)
.await
.unwrap();
// 租户 B 列表查询不应看到租户 A 的用户
let (users_b, total_b) = UserService::list(
tenant_b,
&Pagination {
page: Some(1),
page_size: Some(10),
},
None,
db,
)
.await
.unwrap();
assert_eq!(total_b, 0);
assert!(users_b.is_empty());
// 租户 B 通过 ID 查询租户 A 的用户应返回错误
let result = UserService::get_by_id(user_a.id, tenant_b, db).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_username_uniqueness_within_tenant() {
let test_db = TestDb::new().await;
let db = &test_db.db;
let tenant_id = uuid::Uuid::new_v4();
let operator_id = uuid::Uuid::new_v4();
let event_bus = EventBus::new(100);
let req = CreateUserReq {
username: "duplicate".to_string(),
password: "Pass123456".to_string(),
email: None,
phone: None,
display_name: None,
};
// 第一次创建成功
UserService::create(tenant_id, operator_id, &req, db, &event_bus)
.await
.expect("创建用户应成功");
// 同租户重复用户名应失败
let result = UserService::create(tenant_id, operator_id, &req, db, &event_bus).await;
assert!(result.is_err());
}

View File

@@ -0,0 +1,190 @@
use erp_plugin::dynamic_table::DynamicTableManager;
use erp_plugin::manifest::{
PluginEntity, PluginField, PluginFieldType, PluginManifest, PluginMetadata, PluginSchema,
};
use sea_orm::{ConnectionTrait, FromQueryResult};
use super::test_db::TestDb;
/// 构造一个最小默认值的 PluginField外部 crate 无法使用 #[cfg(test)] 的 default_for_field
fn make_field(name: &str, field_type: PluginFieldType) -> PluginField {
PluginField {
name: name.to_string(),
field_type,
required: false,
unique: false,
default: None,
display_name: None,
ui_widget: None,
options: None,
searchable: None,
filterable: None,
sortable: None,
visible_when: None,
ref_entity: None,
ref_label_field: None,
ref_search_fields: None,
cascade_from: None,
cascade_filter: None,
validation: None,
no_cycle: None,
scope_role: None,
}
}
/// 构建测试用 manifest
fn make_test_manifest() -> PluginManifest {
PluginManifest {
metadata: PluginMetadata {
id: "erp-test".to_string(),
name: "测试插件".to_string(),
version: "0.1.0".to_string(),
description: "集成测试用".to_string(),
author: "test".to_string(),
min_platform_version: None,
dependencies: vec![],
},
schema: Some(PluginSchema {
entities: vec![PluginEntity {
name: "item".to_string(),
display_name: "测试项".to_string(),
fields: vec![
PluginField {
name: "code".to_string(),
field_type: PluginFieldType::String,
required: true,
unique: true,
display_name: Some("编码".to_string()),
searchable: Some(true),
..make_field("code", PluginFieldType::String)
},
PluginField {
name: "name".to_string(),
field_type: PluginFieldType::String,
required: true,
display_name: Some("名称".to_string()),
searchable: Some(true),
..make_field("name", PluginFieldType::String)
},
PluginField {
name: "status".to_string(),
field_type: PluginFieldType::String,
filterable: Some(true),
display_name: Some("状态".to_string()),
..make_field("status", PluginFieldType::String)
},
PluginField {
name: "sort_order".to_string(),
field_type: PluginFieldType::Integer,
sortable: Some(true),
display_name: Some("排序".to_string()),
..make_field("sort_order", PluginFieldType::Integer)
},
],
indexes: vec![],
relations: vec![],
data_scope: None,
}],
}),
events: None,
ui: None,
permissions: None,
}
}
#[tokio::test]
async fn test_dynamic_table_create_and_query() {
let test_db = TestDb::new().await;
let db = &test_db.db;
let manifest = make_test_manifest();
let entity = &manifest.schema.as_ref().unwrap().entities[0];
// 创建动态表
DynamicTableManager::create_table(db, "erp_test", entity)
.await
.expect("创建动态表失败");
let table_name = DynamicTableManager::table_name("erp_test", &entity.name);
// 验证表存在
let exists = DynamicTableManager::table_exists(db, &table_name)
.await
.expect("检查表存在失败");
assert!(exists, "动态表应存在");
// 插入数据
let tenant_id = uuid::Uuid::new_v4();
let user_id = uuid::Uuid::new_v4();
let data = serde_json::json!({
"code": "ITEM001",
"name": "测试项目",
"status": "active",
"sort_order": 1
});
let (sql, values) = DynamicTableManager::build_insert_sql(&table_name, tenant_id, user_id, &data);
db.execute(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.await
.expect("插入数据失败");
// 查询数据
let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_id, 10, 0);
#[derive(FromQueryResult)]
struct Row {
id: uuid::Uuid,
data: serde_json::Value,
}
let rows = Row::find_by_statement(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.all(db)
.await
.expect("查询数据失败");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].data["code"], "ITEM001");
assert_eq!(rows[0].data["name"], "测试项目");
}
#[tokio::test]
async fn test_tenant_isolation_in_dynamic_table() {
let test_db = TestDb::new().await;
let db = &test_db.db;
let manifest = make_test_manifest();
let entity = &manifest.schema.as_ref().unwrap().entities[0];
DynamicTableManager::create_table(db, "erp_test_iso", entity)
.await
.expect("创建动态表失败");
let table_name = DynamicTableManager::table_name("erp_test_iso", &entity.name);
let tenant_a = uuid::Uuid::new_v4();
let tenant_b = uuid::Uuid::new_v4();
let user_id = uuid::Uuid::new_v4();
// 租户 A 插入数据
let data_a = serde_json::json!({"code": "A001", "name": "租户A数据", "status": "active", "sort_order": 1});
let (sql, values) = DynamicTableManager::build_insert_sql(&table_name, tenant_a, user_id, &data_a);
db.execute(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres, sql, values,
)).await.unwrap();
// 租户 B 查询不应看到租户 A 的数据
let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_b, 10, 0);
#[derive(FromQueryResult)]
struct Row { id: uuid::Uuid, data: serde_json::Value }
let rows = Row::find_by_statement(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres, sql, values,
)).all(db).await.unwrap();
assert!(rows.is_empty(), "租户 B 不应看到租户 A 的数据");
}

View File

@@ -0,0 +1,44 @@
use sea_orm::Database;
use erp_server_migration::MigratorTrait;
use testcontainers_modules::postgres::Postgres;
use testcontainers::runners::AsyncRunner;
/// 测试数据库容器 — 启动真实 PostgreSQL 执行迁移后提供 DB 连接
pub struct TestDb {
pub db: sea_orm::DatabaseConnection,
_container: testcontainers::ContainerAsync<Postgres>,
}
impl TestDb {
pub async fn new() -> Self {
let postgres = Postgres::default()
.with_db_name("erp_test")
.with_user("test")
.with_password("test");
let container = postgres
.start()
.await
.expect("启动 PostgreSQL 容器失败");
let host_port = container
.get_host_port_ipv4(5432)
.await
.expect("获取容器端口失败");
let url = format!("postgres://test:test@127.0.0.1:{}/erp_test", host_port);
let db = Database::connect(&url)
.await
.expect("连接测试数据库失败");
// 运行所有迁移
erp_server_migration::Migrator::up(&db, None)
.await
.expect("执行数据库迁移失败");
Self {
db,
_container: container,
}
}
}