feat(test): Week 3 质量保障体系 — 55 新增测试 + CI/CD 流水线
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

前端第二批测试 (42 用例):
- AuthBloc: 16 用例 (启动恢复/登录/注册/角色选择/班级码/登出)
- HomeBloc: 8 用例 (数据加载/今日检测/心情统计/连续天数/离线容错)
- CalendarBloc: 10 用例 (月份切换/日期选择/视图模式/状态保持)
- MoodBloc: 8 用例 (统计加载/周期切换/API解析/错误处理)

后端 P0 单元测试 (13 用例):
- journal_service: 5 用例 (model_to_resp 转换/mood回退/weather回退/tags解析)
- sync_service: 8 用例 (冲突收集/DTO构造/序列化roundtrip/非冲突排除)

CI/CD:
- pr-check.yml: PR 触发 cargo fmt+check+clippy+test + flutter analyze+test
- main-merge.yml: main push 触发完整检查 + cargo audit 安全审计

测试统计: 前端 84 通过, 后端 73 通过 (全部通过)
This commit is contained in:
iven
2026-06-01 23:20:18 +08:00
parent f0921d554c
commit ffde0c9e77
8 changed files with 1460 additions and 0 deletions

View File

@@ -304,3 +304,89 @@ fn model_to_resp(model: journal_entry::Model) -> JournalResp {
updated_at: model.updated_at,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::{Mood, Weather};
use chrono::{NaiveDate, Utc};
use uuid::Uuid;
/// 构造一个带默认值的测试用 journal_entry::Model
fn make_test_model() -> journal_entry::Model {
journal_entry::Model {
id: Uuid::now_v7(),
tenant_id: Uuid::now_v7(),
author_id: Uuid::now_v7(),
class_id: None,
title: "测试日记".to_string(),
date: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(),
mood: "\"happy\"".to_string(),
weather: "\"sunny\"".to_string(),
tags: Some(serde_json::json!(["tag1", "tag2"])),
is_private: true,
shared_to_class: false,
assigned_topic_id: None,
created_at: Utc::now(),
updated_at: Utc::now(),
created_by: Uuid::now_v7(),
updated_by: Uuid::now_v7(),
deleted_at: None,
version: 1,
}
}
#[test]
fn model_to_resp_normal_conversion() {
let model = make_test_model();
let resp = model_to_resp(model);
assert_eq!(resp.title, "测试日记");
assert!(matches!(resp.mood, Mood::Happy));
assert!(matches!(resp.weather, Weather::Sunny));
assert_eq!(resp.tags, vec!["tag1", "tag2"]);
assert!(resp.is_private);
assert!(!resp.shared_to_class);
assert_eq!(resp.version, 1);
assert!(resp.class_id.is_none());
}
#[test]
fn model_to_resp_invalid_mood_falls_back_to_happy() {
let mut model = make_test_model();
// 不是合法 JSON 字符串serde_json::from_str 会失败
model.mood = "invalid_json".to_string();
let resp = model_to_resp(model);
assert!(matches!(resp.mood, Mood::Happy));
}
#[test]
fn model_to_resp_invalid_weather_falls_back_to_sunny() {
let mut model = make_test_model();
// 不是合法 JSON 字符串
model.weather = "xxx".to_string();
let resp = model_to_resp(model);
assert!(matches!(resp.weather, Weather::Sunny));
}
#[test]
fn model_to_resp_tags_none_yields_empty_list() {
let mut model = make_test_model();
model.tags = None;
let resp = model_to_resp(model);
assert!(resp.tags.is_empty());
}
#[test]
fn model_to_resp_tags_not_array_yields_empty_list() {
let mut model = make_test_model();
// 有效 JSON 但不是数组 → serde_json::from_value::<Vec<String>> 失败
model.tags = Some(serde_json::json!("not_an_array"));
let resp = model_to_resp(model);
assert!(resp.tags.is_empty());
}
}

View File

@@ -234,3 +234,169 @@ impl SyncService {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::SyncChange;
#[test]
fn conflict_info_construction() {
let journal_id = uuid::Uuid::now_v7();
let info = ConflictInfo {
journal_id,
local_version: 2,
server_version: 5,
};
assert_eq!(info.journal_id, journal_id);
assert_eq!(info.local_version, 2);
assert_eq!(info.server_version, 5);
}
#[test]
fn conflict_info_serializes_with_correct_fields() {
let info = ConflictInfo {
journal_id: uuid::Uuid::nil(),
local_version: 1,
server_version: 3,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"local_version\":1"));
assert!(json.contains("\"server_version\":3"));
assert!(json.contains("\"journal_id\""));
}
#[test]
fn sync_change_create_journal_carries_data() {
let data = serde_json::json!({
"title": "我的日记",
"mood": "happy"
});
let change = SyncChange::CreateJournal { data: data.clone() };
// 验证 match 可以正确提取 data
match &change {
SyncChange::CreateJournal { data } => {
assert_eq!(data.get("title").unwrap().as_str().unwrap(), "我的日记");
assert_eq!(data.get("mood").unwrap().as_str().unwrap(), "happy");
}
_ => panic!("Expected CreateJournal variant"),
}
}
#[test]
fn sync_change_update_version_extraction() {
let id = uuid::Uuid::now_v7();
let change = SyncChange::UpdateJournal {
id,
version: 7,
data: serde_json::json!({"title": "更新标题"}),
};
match &change {
SyncChange::UpdateJournal {
id: cid,
version,
data,
} => {
assert_eq!(*cid, id);
assert_eq!(*version, 7);
assert_eq!(data.get("title").unwrap().as_str().unwrap(), "更新标题");
}
_ => panic!("Expected UpdateJournal variant"),
}
}
#[test]
fn sync_change_delete_version_extraction() {
let id = uuid::Uuid::now_v7();
let change = SyncChange::DeleteJournal { id, version: 3 };
match &change {
SyncChange::DeleteJournal {
id: cid,
version,
} => {
assert_eq!(*cid, id);
assert_eq!(*version, 3);
}
_ => panic!("Expected DeleteJournal variant"),
}
}
#[test]
fn sync_change_roundtrip_serialization() {
let change = SyncChange::UpdateJournal {
id: uuid::Uuid::nil(),
version: 2,
data: serde_json::json!({"title": "test"}),
};
let json = serde_json::to_string(&change).unwrap();
let back: SyncChange = serde_json::from_str(&json).unwrap();
match back {
SyncChange::UpdateJournal { version, .. } => assert_eq!(version, 2),
_ => panic!("Expected UpdateJournal after roundtrip"),
}
}
#[test]
fn conflict_collection_pattern_mimics_sync_logic() {
// 模拟 sync 方法中 VersionConflict 收集到 conflicts 列表的行为
let mut conflicts: Vec<ConflictInfo> = Vec::new();
let errors: Vec<DiaryError> = vec![
DiaryError::VersionConflict {
local: 1,
server: 3,
},
DiaryError::VersionConflict {
local: 2,
server: 5,
},
];
for e in errors {
match e {
DiaryError::VersionConflict { local, server } => {
conflicts.push(ConflictInfo {
journal_id: uuid::Uuid::nil(),
local_version: local,
server_version: server,
});
}
_ => {}
}
}
assert_eq!(conflicts.len(), 2);
assert_eq!(conflicts[0].local_version, 1);
assert_eq!(conflicts[0].server_version, 3);
assert_eq!(conflicts[1].local_version, 2);
assert_eq!(conflicts[1].server_version, 5);
}
#[test]
fn non_conflict_error_does_not_collect() {
// 验证非 VersionConflict 错误不会被收集到 conflicts 列表
let mut conflicts: Vec<ConflictInfo> = Vec::new();
let error = DiaryError::NotFound("日记不存在".to_string());
match error {
DiaryError::VersionConflict { local, server } => {
conflicts.push(ConflictInfo {
journal_id: uuid::Uuid::nil(),
local_version: local,
server_version: server,
});
}
_ => {
// 其他错误不应收集
}
}
assert!(conflicts.is_empty());
}
}