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

@@ -4,6 +4,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/masters/user/data/repositories/user_repository_remote.dart';
import 'package:superport_v2/features/masters/user/domain/entities/user.dart';
class _MockApiClient extends Mock implements ApiClient {}
@@ -14,6 +15,9 @@ void main() {
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
registerFallbackValue(
Response<dynamic>(requestOptions: RequestOptions(path: '/fallback')),
);
});
setUp(() {
@@ -35,13 +39,15 @@ void main() {
'items': [
{
'id': 1,
'employee_no': 'E-001',
'employee_name': '홍길동',
'employee_id': 'E-001',
'name': '홍길동',
'group': {'id': 2, 'group_name': '관리자'},
'force_password_change': false,
'password_updated_at': '2025-01-10T09:00:00Z',
},
],
},
requestOptions: RequestOptions(path: '/api/v1/employees'),
requestOptions: RequestOptions(path: '/api/v1/users'),
statusCode: 200,
),
);
@@ -59,7 +65,150 @@ void main() {
final path = captured[0] as String;
final query = captured[1] as Map<String, dynamic>;
expect(path, equals('/api/v1/employees'));
expect(path, equals('/api/v1/users'));
expect(query['include'], 'group');
});
test('create 호출 시 employee_id 파라미터를 전달한다', () async {
final response = Response<Map<String, dynamic>>(
data: {
'data': {
'id': 10,
'employee_id': 'E2025001',
'name': '김승인',
'email': 'approver@example.com',
'phone': '+82-10-1111-2222',
'group': {'id': 1, 'group_name': '관리자'},
'force_password_change': true,
'is_active': true,
},
},
requestOptions: RequestOptions(path: '/api/v1/users'),
statusCode: 201,
);
when(
() => apiClient.post<Map<String, dynamic>>(
any(),
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer((_) async => response);
when(
() => apiClient.unwrapAsMap(response),
).thenReturn(response.data!['data'] as Map<String, dynamic>);
final input = UserInput(
employeeNo: 'E2025001',
employeeName: '김승인',
groupId: 1,
email: 'approver@example.com',
mobileNo: '+82-10-1111-2222',
password: 'TempPass!1',
);
final user = await repository.create(input);
expect(user.employeeNo, 'E2025001');
expect(user.forcePasswordChange, isTrue);
verify(
() => apiClient.post<Map<String, dynamic>>(
'/api/v1/users',
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
test('updateMe 호출 시 /users/me 엔드포인트를 사용한다', () async {
final response = Response<Map<String, dynamic>>(
data: {
'data': {
'id': 7,
'employee_id': 'E2025001',
'name': '김승인',
'email': 'approver@example.com',
'phone': '+82-10-1111-2222',
'force_password_change': false,
},
},
requestOptions: RequestOptions(path: '/api/v1/users/me'),
statusCode: 200,
);
when(
() => apiClient.patch<Map<String, dynamic>>(
any(),
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer((_) async => response);
when(
() => apiClient.unwrapAsMap(response),
).thenReturn(response.data!['data'] as Map<String, dynamic>);
final payload = UserProfileUpdateInput(
email: 'approver@example.com',
phone: '+82-10-1111-2222',
password: 'NewPass!23',
currentPassword: 'TempPass!1',
);
final user = await repository.updateMe(payload);
expect(user.email, 'approver@example.com');
verify(
() => apiClient.patch<Map<String, dynamic>>(
'/api/v1/users/me',
data: payload.toPayload(),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
test('resetPassword 호출 시 /users/{id}/reset-password 엔드포인트를 사용한다', () async {
final response = Response<Map<String, dynamic>>(
data: {
'data': {
'id': 7,
'employee_id': 'E2025001',
'email': 'approver@example.com',
'force_password_change': true,
'password_updated_at': '2025-03-11T02:05:00Z',
},
},
requestOptions: RequestOptions(path: '/api/v1/users/7/reset-password'),
statusCode: 200,
);
when(
() => apiClient.post<Map<String, dynamic>>(
any(),
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer((_) async => response);
when(
() => apiClient.unwrapAsMap(response),
).thenReturn(response.data!['data'] as Map<String, dynamic>);
final user = await repository.resetPassword(7);
expect(user.forcePasswordChange, isTrue);
expect(user.passwordUpdatedAt, isNotNull);
verify(
() => apiClient.post<Map<String, dynamic>>(
'/api/v1/users/7/reset-password',
data: null,
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
}

View File

@@ -269,5 +269,16 @@ void main() {
expect(restored, isNotNull);
verify(() => userRepository.restore(1)).called(1);
});
test('resetPassword 성공', () async {
when(
() => userRepository.resetPassword(any()),
).thenAnswer((_) async => sampleUser);
final result = await controller.resetPassword(1);
expect(result, isNotNull);
verify(() => userRepository.resetPassword(1)).called(1);
});
});
}

View File

@@ -200,7 +200,7 @@ void main() {
testWidgets('신규 등록 성공', (tester) async {
final view = tester.view;
view.physicalSize = const Size(1280, 800);
view.physicalSize = const Size(1600, 900);
view.devicePixelRatio = 1.0;
addTearDown(() {
view.resetPhysicalSize();
@@ -260,13 +260,26 @@ void main() {
await tester.pumpAndSettle();
final dialog = find.byType(Dialog);
final editableTexts = find.descendant(
of: dialog,
matching: find.byType(EditableText),
await tester.enterText(
find.byKey(const ValueKey('user_form_employee')),
'A010',
);
await tester.enterText(
find.byKey(const ValueKey('user_form_name')),
'신규 사용자',
);
await tester.enterText(
find.byKey(const ValueKey('user_form_password')),
'Aa1!abcd',
);
await tester.enterText(
find.byKey(const ValueKey('user_form_email')),
'new@superport.com',
);
await tester.enterText(
find.byKey(const ValueKey('user_form_phone')),
'010-1111-2222',
);
await tester.enterText(editableTexts.at(0), 'A010');
await tester.enterText(editableTexts.at(1), '신규 사용자');
final selectFinder = find.descendant(
of: dialog,
@@ -290,9 +303,149 @@ void main() {
expect(capturedInput, isNotNull);
expect(capturedInput?.employeeNo, 'A010');
expect(capturedInput?.password, 'Aa1!abcd');
expect(capturedInput?.forcePasswordChange, isTrue);
expect(capturedInput?.email, 'new@superport.com');
expect(capturedInput?.mobileNo, '010-1111-2222');
expect(find.byType(Dialog), findsNothing);
expect(find.text('A010'), findsOneWidget);
verify(() => userRepository.create(any())).called(1);
});
testWidgets('비밀번호 정책을 위반하면 에러가 노출되고 생성이 중단된다', (tester) async {
final view = tester.view;
view.physicalSize = const Size(1280, 800);
view.devicePixelRatio = 1.0;
addTearDown(() {
view.resetPhysicalSize();
view.resetDevicePixelRatio();
});
when(
() => userRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
groupId: any(named: 'groupId'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<UserAccount>(
items: const [],
page: 1,
pageSize: 20,
total: 0,
),
);
await tester.pumpWidget(_buildApp(const UserPage()));
await tester.pumpAndSettle();
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
final dialog = find.byType(Dialog);
await tester.enterText(
find.byKey(const ValueKey('user_form_employee')),
'A011',
);
await tester.enterText(
find.byKey(const ValueKey('user_form_name')),
'정책 위반',
);
await tester.enterText(
find.byKey(const ValueKey('user_form_password')),
'abc',
);
await tester.enterText(
find.byKey(const ValueKey('user_form_email')),
'invalid@superport.com',
);
await tester.enterText(
find.byKey(const ValueKey('user_form_phone')),
'010-3333-4444',
);
final selectFinder = find.descendant(
of: dialog,
matching: find.byType(ShadSelect<int?>),
);
final selectElement = tester.element(selectFinder);
final renderBox = selectElement.renderObject as RenderBox;
final globalCenter = renderBox.localToGlobal(
renderBox.size.center(Offset.zero),
);
await tester.tapAt(globalCenter);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.tap(find.text('관리자', skipOffstage: false).first);
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.pump();
expect(find.textContaining('최소 8자 이상 입력해야 합니다.'), findsOneWidget);
verifyNever(() => userRepository.create(any()));
});
testWidgets('비밀번호 재설정 버튼을 통해 확인 후 API 호출', (tester) async {
final view = tester.view;
view.physicalSize = const Size(1280, 800);
view.devicePixelRatio = 1.0;
addTearDown(() {
view.resetPhysicalSize();
view.resetDevicePixelRatio();
});
when(
() => userRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
groupId: any(named: 'groupId'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<UserAccount>(
items: [
UserAccount(
id: 1,
employeeNo: 'A001',
employeeName: '홍길동',
group: UserGroup(id: 1, groupName: '관리자'),
),
],
page: 1,
pageSize: 20,
total: 1,
),
);
when(() => userRepository.resetPassword(any())).thenAnswer(
(_) async => UserAccount(
id: 1,
employeeNo: 'A001',
employeeName: '홍길동',
group: UserGroup(id: 1, groupName: '관리자'),
),
);
await tester.pumpWidget(_buildApp(const UserPage()));
await tester.pumpAndSettle();
final resetFinder = find
.widgetWithIcon(ShadButton, LucideIcons.refreshCcw)
.first;
final resetButton = tester.widget<ShadButton>(resetFinder);
resetButton.onPressed?.call();
await tester.pumpAndSettle();
expect(find.byType(Dialog), findsOneWidget);
expect(find.text('재설정'), findsOneWidget);
await tester.tap(find.text('재설정'));
await tester.pumpAndSettle();
verify(() => userRepository.resetPassword(1)).called(1);
expect(find.text('비밀번호 재설정'), findsNothing);
});
});
}