fix: seed production defaults and harden onboarding
This commit is contained in:
@@ -131,6 +131,30 @@ public sealed class ApiFixture : IAsyncLifetime
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class ApiIntegrationTests(ApiFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublicRuntimeDefaults_AreAvailableForProductionFlows()
|
||||
{
|
||||
using var publicClient = fixture.Factory.CreateClient();
|
||||
var avatars = await publicClient.GetFromJsonAsync<JsonElement>(
|
||||
"/api/public/avatars");
|
||||
var personas = await publicClient.GetFromJsonAsync<JsonElement>(
|
||||
"/api/public/personas");
|
||||
|
||||
Assert.Contains(avatars.EnumerateArray(), item =>
|
||||
item.GetProperty("key").GetString() == "cat");
|
||||
Assert.Contains(personas.EnumerateArray(), item =>
|
||||
item.GetProperty("key").GetString() == "sassy_cat");
|
||||
|
||||
using var user = await fixture.RegisterAsync("runtime_defaults_user");
|
||||
foreach (var type in new[] { "expense", "income" })
|
||||
{
|
||||
var categories = await user.GetFromJsonAsync<JsonElement>(
|
||||
$"/api/categories?type={type}");
|
||||
Assert.Contains(categories.EnumerateArray(), item =>
|
||||
item.GetProperty("name").GetString() == "其他");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataIsolation_TimeZone_Recycle_Budget_AndExport_WorkTogether()
|
||||
{
|
||||
|
||||
@@ -168,8 +168,11 @@ using (var scope = app.Services.CreateScope())
|
||||
await db.Database.MigrateAsync();
|
||||
await AppConfigDefaults.EnsureAsync(db);
|
||||
await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync();
|
||||
if (app.Environment.IsDevelopment())
|
||||
await DbSeeder.SeedAsync(db);
|
||||
// These records are runtime defaults, not development fixtures. Production
|
||||
// databases also need them for onboarding, category fallback and stickers.
|
||||
// DbSeeder only inserts into an empty catalog, so existing admin-managed
|
||||
// records are preserved.
|
||||
await DbSeeder.SeedAsync(db);
|
||||
}
|
||||
if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
|
||||
@@ -31,37 +31,42 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
}
|
||||
|
||||
Future<void> _loadConfig() async {
|
||||
if (mounted) setState(() => _loaded = false);
|
||||
final results = await Future.wait([_loadAvatars(), _loadPersonas()]);
|
||||
if (!mounted) return;
|
||||
final avatars = results[0] as List<AvatarItem>;
|
||||
final personas = results[1] as List<PersonaItem>;
|
||||
setState(() {
|
||||
_avatars = avatars;
|
||||
_personas = personas;
|
||||
if (!avatars.any((item) => item.key == _avatar) && avatars.isNotEmpty) {
|
||||
_avatar = avatars.first.key;
|
||||
}
|
||||
if (!personas.any((item) => item.key == _persona) && personas.isNotEmpty) {
|
||||
_persona = personas.first.key;
|
||||
}
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<AvatarItem>> _loadAvatars() async {
|
||||
try {
|
||||
final avatars = await PublicConfigApi.avatars();
|
||||
final personas = await PublicConfigApi.personas();
|
||||
if (mounted)
|
||||
setState(() {
|
||||
_avatars = avatars;
|
||||
_personas = personas;
|
||||
if (avatars.isNotEmpty) _avatar = avatars.first.key;
|
||||
if (personas.isNotEmpty) _persona = personas.first.key;
|
||||
_loaded = true;
|
||||
});
|
||||
return await PublicConfigApi.avatars();
|
||||
} catch (_) {
|
||||
// API 失败用硬编码兜底
|
||||
if (mounted) setState(() => _loaded = true);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底数据(API 不可用时)
|
||||
static const _fallbackAvatars = [
|
||||
('cat', '小账喵', AppIcons.cat),
|
||||
('dog', '阿福汪', AppIcons.dog),
|
||||
('robot', '账小智', AppIcons.robot),
|
||||
];
|
||||
static const _fallbackPersonas = [
|
||||
('sassy_cat', '毒舌猫娘', '乱花钱会被无情吐槽'),
|
||||
('gentle', '温柔小暖', '永远鼓励,温柔提醒'),
|
||||
('strict', '严格管家', '理性专业,数据说话'),
|
||||
('meme', '沙雕损友', '玩梗高手,快乐记账'),
|
||||
];
|
||||
Future<List<PersonaItem>> _loadPersonas() async {
|
||||
try {
|
||||
return await PublicConfigApi.personas();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _finish() async {
|
||||
if (!_hasValidCatalogSelection) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await AuthApi.completeOnboarding(
|
||||
@@ -81,6 +86,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
}
|
||||
}
|
||||
|
||||
bool get _hasValidCatalogSelection =>
|
||||
_avatars.any((item) => item.key == _avatar) &&
|
||||
_personas.any((item) => item.key == _persona);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_loaded)
|
||||
@@ -170,9 +179,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
}
|
||||
|
||||
Widget _CompanionStep() {
|
||||
// 优先 API 数据,回退硬编码
|
||||
final avatars = _avatars.isNotEmpty
|
||||
? _avatars
|
||||
final avatars = _avatars
|
||||
.map(
|
||||
(a) => (
|
||||
a.key,
|
||||
@@ -184,11 +191,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
: AppIcons.cat,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
: _fallbackAvatars;
|
||||
final personas = _personas.isNotEmpty
|
||||
? _personas.map((p) => (p.key, p.name, p.description)).toList()
|
||||
: _fallbackPersonas;
|
||||
.toList();
|
||||
final personas = _personas
|
||||
.map((p) => (p.key, p.name, p.description))
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -226,6 +232,35 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
style: TextStyle(fontSize: 12, color: context.jz.text2),
|
||||
),
|
||||
SizedBox(height: 14),
|
||||
if (avatars.isEmpty || personas.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcons.icon(AppIcons.cloud, size: 28, color: context.jz.text3),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'AI 伙伴配置暂不可用',
|
||||
style: TextStyle(
|
||||
color: context.jz.text,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('请检查网络后重试', style: TextStyle(color: context.jz.text2, fontSize: 12)),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
onPressed: _loadConfig,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('重新加载'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
SizedBox(
|
||||
height: 96,
|
||||
child: Row(
|
||||
@@ -304,7 +339,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
curve: Curves.easeOut,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? context.jz.aiBackground : Colors.white,
|
||||
color: on ? context.jz.aiBackground : context.jz.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: on ? AppTheme.ai : context.jz.line,
|
||||
@@ -339,11 +374,12 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
|
||||
onPressed: _saving ? null : _finish,
|
||||
onPressed: _saving || !_hasValidCatalogSelection ? null : _finish,
|
||||
child: _saving
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
|
||||
@@ -48,6 +48,8 @@ class GuestMergeService {
|
||||
(response.data as List).map((item) => item as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
await _ensureFallbackCategory(available, 'expense');
|
||||
await _ensureFallbackCategory(available, 'income');
|
||||
|
||||
for (final value in snapshot['categories'] as List<dynamic>? ?? const []) {
|
||||
final category = value as Map<String, dynamic>;
|
||||
@@ -92,6 +94,7 @@ class GuestMergeService {
|
||||
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
|
||||
final fallback = available.firstWhere(
|
||||
(item) => item['type'] == categoryType && item['name'] == '其他',
|
||||
orElse: () => throw StateError('$categoryType 分类缺少“其他”,无法导入本机账单'),
|
||||
);
|
||||
return (fallback['id'] as num).toInt();
|
||||
}
|
||||
@@ -151,6 +154,40 @@ class GuestMergeService {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _ensureFallbackCategory(
|
||||
List<Map<String, dynamic>> available,
|
||||
String type,
|
||||
) async {
|
||||
bool hasFallback() => available.any(
|
||||
(item) => item['type'] == type && item['name'] == '其他',
|
||||
);
|
||||
if (hasFallback()) return;
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/categories',
|
||||
data: {
|
||||
'name': '其他',
|
||||
'iconKey': 'tag',
|
||||
'colorKey': type == 'income' ? 'lime' : 'graphite',
|
||||
'type': type,
|
||||
},
|
||||
);
|
||||
available.add(response.data as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
// Another request may have created it between the list and create calls.
|
||||
final response = await _dio.get(
|
||||
'/api/categories',
|
||||
queryParameters: {'type': type},
|
||||
);
|
||||
available.removeWhere((item) => item['type'] == type);
|
||||
available.addAll(
|
||||
(response.data as List).map((item) => item as Map<String, dynamic>),
|
||||
);
|
||||
if (!hasFallback()) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static int? _findMappedDefault(
|
||||
List<Map<String, dynamic>> available,
|
||||
Map<String, dynamic> snapshot,
|
||||
|
||||
@@ -68,6 +68,9 @@ void main() {
|
||||
final companion = File(
|
||||
'lib/features/settings/companion_page.dart',
|
||||
).readAsStringSync();
|
||||
final onboarding = File(
|
||||
'lib/features/onboarding/pages/onboarding_page.dart',
|
||||
).readAsStringSync();
|
||||
final me = File('lib/features/settings/me_page.dart').readAsStringSync();
|
||||
final report = File(
|
||||
'lib/features/stats/report_page.dart',
|
||||
@@ -80,10 +83,27 @@ void main() {
|
||||
companion,
|
||||
isNot(contains('context.jz.aiBackground : Colors.white')),
|
||||
);
|
||||
expect(onboarding, contains(': context.jz.card'));
|
||||
expect(
|
||||
onboarding,
|
||||
isNot(contains('context.jz.aiBackground : Colors.white')),
|
||||
);
|
||||
expect(me, isNot(contains('context.jz.primaryBackground : Colors.white')));
|
||||
expect(report, isNot(contains('selected ? Colors.white')));
|
||||
});
|
||||
|
||||
test('首次引导只提交服务端实际返回的 AI 伙伴配置', () {
|
||||
final onboarding = File(
|
||||
'lib/features/onboarding/pages/onboarding_page.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(onboarding, contains('_hasValidCatalogSelection'));
|
||||
expect(onboarding, contains('AI 伙伴配置暂不可用'));
|
||||
expect(onboarding, contains('重新加载'));
|
||||
expect(onboarding, isNot(contains('_fallbackAvatars')));
|
||||
expect(onboarding, isNot(contains('_fallbackPersonas')));
|
||||
});
|
||||
|
||||
test('聊天附件只保留拍照和相册导入', () {
|
||||
final source = File('lib/features/chat/chat_page.dart').readAsStringSync();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user