feat(user): 사용자 자기정보 편집과 관리자 재설정 플로우를 연동

- lib/widgets/app_shell.dart에서 내 정보 다이얼로그를 추가하고 UserRepository.updateMe·비밀번호 변경 로직을 연결

- lib/features/masters/user/* 모듈에 phone·forcePasswordChange·passwordUpdatedAt 필드를 반영하고 reset-password/update-me API를 사용

- lib/core/validation/password_rules.dart을 신설해 비밀번호 정책 검증을 공통화하고 신규 위젯·테스트에서 재사용

- doc/stock_approval_system_api_v4.md 등 문서를 users 스펙 개편 내용으로 갱신하고 user_management_plan.md를 추가

- test/widgets/app_shell_test.dart 등에서 자기정보 수정·비밀번호 재설정 시나리오를 검증하고 기존 테스트를 보강
This commit is contained in:
JiWoong Sul
2025-10-26 17:05:47 +09:00
parent 9beb161527
commit 14624c4165
23 changed files with 1958 additions and 194 deletions

View File

@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/constants/app_sections.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
@@ -16,6 +17,8 @@ import 'package:superport_v2/features/auth/domain/entities/auth_session.dart';
import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart';
import 'package:superport_v2/features/auth/domain/entities/login_request.dart';
import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart';
import 'package:superport_v2/features/masters/user/domain/entities/user.dart';
import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart';
import 'package:superport_v2/widgets/app_shell.dart';
void main() {
@@ -27,6 +30,7 @@ void main() {
final session = _buildSession();
final authService = _createAuthService(session);
GetIt.I.registerSingleton<AuthService>(authService);
GetIt.I.registerSingleton<UserRepository>(_StubUserRepository());
addTearDown(authService.dispose);
await authService.login(
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
@@ -37,10 +41,12 @@ void main() {
await tester.tap(find.byIcon(lucide.LucideIcons.userRound));
await tester.pumpAndSettle();
expect(find.text('계정 정보'), findsOneWidget);
expect(find.text(' 정보'), findsOneWidget);
expect(find.text('김승인'), findsWidgets);
expect(find.text('E2025001'), findsOneWidget);
expect(find.text('물류팀'), findsOneWidget);
expect(find.text('연락처 / 이메일 수정'), findsOneWidget);
expect(find.text('비밀번호 변경'), findsOneWidget);
expect(find.textContaining('/approvals'), findsOneWidget);
});
@@ -48,6 +54,7 @@ void main() {
final session = _buildSession();
final authService = _createAuthService(session);
GetIt.I.registerSingleton<AuthService>(authService);
GetIt.I.registerSingleton<UserRepository>(_StubUserRepository());
addTearDown(authService.dispose);
await authService.login(
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
@@ -66,6 +73,133 @@ void main() {
expect(authService.session, isNull);
expect(find.text('로그인 페이지'), findsOneWidget);
});
testWidgets('내 정보 다이얼로그에서 연락처/이메일 저장 시 updateMe 호출', (tester) async {
final session = _buildSession();
final authService = _createAuthService(session);
final captured = <UserProfileUpdateInput>[];
final repository = _StubUserRepository(onUpdateMe: (input) async {
captured.add(input);
return UserAccount(
id: session.user.id,
employeeNo: session.user.employeeNo ?? '',
employeeName: session.user.name,
email: input.email,
mobileNo: input.phone,
group: UserGroup(id: 1, groupName: '물류팀'),
);
});
GetIt.I.registerSingleton<AuthService>(authService);
GetIt.I.registerSingleton<UserRepository>(repository);
addTearDown(authService.dispose);
await authService.login(
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
);
final view = tester.view;
view.physicalSize = const Size(1600, 900);
view.devicePixelRatio = 1.0;
addTearDown(() {
view.resetPhysicalSize();
view.resetDevicePixelRatio();
});
await _pumpAppShell(tester);
await tester.tap(find.byIcon(lucide.LucideIcons.userRound));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('account_email_field')),
'new@superport.com',
);
await tester.enterText(
find.byKey(const ValueKey('account_phone_field')),
'+82-10-9999-8888',
);
await tester.tap(find.text('저장'));
await tester.pumpAndSettle();
expect(captured, hasLength(1));
final input = captured.single;
expect(input.email, 'new@superport.com');
expect(input.phone, '+82-10-9999-8888');
expect(authService.session?.user.email, 'new@superport.com');
expect(authService.session?.user.phone, '+82-10-9999-8888');
});
testWidgets('비밀번호 변경 완료 시 강제 로그아웃 안내 후 세션 초기화', (tester) async {
final session = _buildSession();
final authService = _createAuthService(session);
UserProfileUpdateInput? passwordInput;
final repository = _StubUserRepository(onUpdateMe: (input) async {
passwordInput = input;
return UserAccount(
id: session.user.id,
employeeNo: session.user.employeeNo ?? '',
employeeName: session.user.name,
email: input.email ?? session.user.email,
mobileNo: input.phone ?? session.user.phone,
group: UserGroup(id: 1, groupName: '물류팀'),
);
});
GetIt.I.registerSingleton<AuthService>(authService);
GetIt.I.registerSingleton<UserRepository>(repository);
addTearDown(authService.dispose);
await authService.login(
const LoginRequest(identifier: 'user@example.com', password: 'secret'),
);
final view = tester.view;
view.physicalSize = const Size(1600, 900);
view.devicePixelRatio = 1.0;
addTearDown(() {
view.resetPhysicalSize();
view.resetDevicePixelRatio();
});
await _pumpAppShell(tester);
await tester.tap(find.byIcon(lucide.LucideIcons.userRound));
await tester.pumpAndSettle();
final changeButton = find.text('비밀번호 변경');
await tester.ensureVisible(changeButton);
await tester.tap(changeButton);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('account_current_password')),
'TempPass1!',
);
await tester.enterText(
find.byKey(const ValueKey('account_new_password')),
'Aa1!zzzz',
);
await tester.enterText(
find.byKey(const ValueKey('account_confirm_password')),
'Aa1!zzzz',
);
await tester.tap(find.text('변경'));
await tester.pump();
expect(passwordInput, isNotNull);
expect(passwordInput!.currentPassword, 'TempPass1!');
expect(passwordInput!.password, 'Aa1!zzzz');
await tester.pumpAndSettle();
expect(find.text('비밀번호 변경 완료'), findsOneWidget);
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(authService.session, isNull);
expect(find.text('로그인 페이지'), findsOneWidget);
});
}
Future<void> _pumpAppShell(WidgetTester tester) async {
@@ -122,6 +256,7 @@ AuthSession _buildSession() {
name: '김승인',
employeeNo: 'E2025001',
email: 'approver@example.com',
phone: '+82-10-2222-1111',
primaryGroupId: 3,
primaryGroupName: '물류팀',
),
@@ -194,3 +329,53 @@ class _LoginPlaceholder extends StatelessWidget {
return const Scaffold(body: Center(child: Text('로그인 페이지')));
}
}
class _StubUserRepository implements UserRepository {
_StubUserRepository({this.onUpdateMe});
final Future<UserAccount> Function(UserProfileUpdateInput input)? onUpdateMe;
@override
Future<UserAccount> create(UserInput input) {
throw UnimplementedError();
}
@override
Future<void> delete(int id) {
throw UnimplementedError();
}
@override
Future<PaginatedResult<UserAccount>> list({
int page = 1,
int pageSize = 20,
String? query,
int? groupId,
bool? isActive,
}) {
throw UnimplementedError();
}
@override
Future<UserAccount> resetPassword(int id) {
throw UnimplementedError();
}
@override
Future<UserAccount> restore(int id) {
throw UnimplementedError();
}
@override
Future<UserAccount> update(int id, UserInput input) {
throw UnimplementedError();
}
@override
Future<UserAccount> updateMe(UserProfileUpdateInput input) {
if (onUpdateMe != null) {
return onUpdateMe!(input);
}
throw UnimplementedError();
}
}