feat(frontend): 승인 템플릿 API 통합 및 디버그 로그인 확장

- docs 폴더 문서를 최신 API 계약으로 갱신하고 가이드를 다듬었다\n- approvals data/presentation 레이어를 API v4 스펙에 맞춰 리팩터링했다\n- approver 자동완성 위젯을 신규 공유 레포지토리에 맞춰 교체하고 UX를 보강했다\n- inventory/rental 페이지 테이블 초기화 시 승인 기준 연동을 정비했다\n- 로그인 페이지 디버그 버튼을 tera/exa 계정으로 분리해 QA 로그인을 단순화했다\n- get_it 등록과 테스트 케이스를 신규 공유 리포지토리에 맞춰 업데이트했다
This commit is contained in:
JiWoong Sul
2025-11-05 17:05:38 +09:00
parent 3e83408aa7
commit fa0bda5ea4
28 changed files with 1102 additions and 545 deletions

View File

@@ -1,13 +1,30 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart';
import 'package:superport_v2/features/approvals/request/presentation/utils/approval_form_initializer.dart';
import 'package:superport_v2/features/approvals/shared/domain/entities/approval_approver_candidate.dart';
import 'package:superport_v2/features/approvals/shared/domain/repositories/approval_approver_repository.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
void main() {
final getIt = GetIt.I;
setUp(() {
if (getIt.isRegistered<ApprovalApproverRepository>()) {
getIt.unregister<ApprovalApproverRepository>();
}
});
tearDown(() async {
if (getIt.isRegistered<ApprovalApproverRepository>()) {
getIt.unregister<ApprovalApproverRepository>();
}
});
group('ApprovalFormInitializer.populate', () {
test('초안 저장본이 있으면 상신자와 단계를 복구한다', () {
test('초안 저장본이 있으면 상신자와 단계를 복구한다', () async {
final controller = ApprovalRequestController();
final draft = StockTransactionApprovalInput(
requestedById: 101,
@@ -17,7 +34,18 @@ void main() {
],
);
ApprovalFormInitializer.populate(controller: controller, draft: draft);
getIt.registerSingleton<ApprovalApproverRepository>(
_FakeApproverRepository({
101: _candidate(id: 101, name: '요청자'),
104: _candidate(id: 104, name: '1단계 승인자'),
201: _candidate(id: 201, name: '최종 승인자'),
}),
);
await ApprovalFormInitializer.populate(
controller: controller,
draft: draft,
);
expect(controller.requester?.id, equals(101));
expect(controller.steps.length, equals(2));
@@ -25,7 +53,7 @@ void main() {
expect(controller.steps.last.approver.id, equals(201));
});
test('카탈로그에 없는 승인자는 복구 대상에서 제외한다', () {
test('조회 실패 시 해당 승인자는 복구 대상에서 제외한다', () async {
final controller = ApprovalRequestController();
final draft = StockTransactionApprovalInput(
requestedById: 101,
@@ -35,10 +63,53 @@ void main() {
],
);
ApprovalFormInitializer.populate(controller: controller, draft: draft);
getIt.registerSingleton<ApprovalApproverRepository>(
_FakeApproverRepository({
101: _candidate(id: 101, name: '요청자'),
104: _candidate(id: 104, name: '1단계 승인자'),
}),
);
await ApprovalFormInitializer.populate(
controller: controller,
draft: draft,
);
expect(controller.steps.length, equals(1));
expect(controller.steps.first.approver.id, equals(104));
});
});
}
ApprovalApproverCandidate _candidate({required int id, required String name}) {
return ApprovalApproverCandidate(
id: id,
employeeNo: 'EMP$id',
name: name,
team: '',
);
}
class _FakeApproverRepository implements ApprovalApproverRepository {
_FakeApproverRepository(this._candidates);
final Map<int, ApprovalApproverCandidate> _candidates;
@override
Future<ApprovalApproverCandidate?> fetchById(int id) async {
return _candidates[id];
}
@override
Future<List<ApprovalApproverCandidate>> search({
required String keyword,
int limit = 20,
}) async {
return _candidates.values.toList(growable: false);
}
@override
Future<List<ApprovalApproverCandidate>> listInitial({int limit = 20}) async {
return _candidates.values.take(limit).toList(growable: false);
}
}

View File

@@ -12,6 +12,11 @@ import 'package:superport_v2/features/approvals/domain/repositories/approval_tem
import 'package:superport_v2/features/approvals/domain/usecases/apply_approval_template_use_case.dart';
import 'package:superport_v2/features/approvals/domain/usecases/save_approval_template_use_case.dart';
import 'package:superport_v2/features/approvals/template/presentation/pages/approval_template_page.dart';
import 'package:superport_v2/features/approvals/shared/domain/entities/approval_approver_candidate.dart';
import 'package:superport_v2/features/approvals/shared/domain/repositories/approval_approver_repository.dart';
import 'package:superport_v2/features/auth/application/auth_service.dart';
import 'package:superport_v2/features/auth/domain/entities/auth_session.dart';
import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart';
class _MockApprovalTemplateRepository extends Mock
implements ApprovalTemplateRepository {}
@@ -23,6 +28,8 @@ class _FakeTemplateInput extends Fake implements ApprovalTemplateInput {}
class _FakeTemplateStepInput extends Fake
implements ApprovalTemplateStepInput {}
class _MockAuthService extends Mock implements AuthService {}
Widget _buildApp(Widget child) {
return MaterialApp(
home: ShadTheme(
@@ -82,6 +89,37 @@ void main() {
approvalRepository: approvalRepository,
),
);
GetIt.I.registerLazySingleton<ApprovalApproverRepository>(
() => _FakeApproverRepository({
33: ApprovalApproverCandidate(
id: 33,
employeeNo: 'EMP033',
name: '테스트 승인자',
team: 'QA팀',
),
21: ApprovalApproverCandidate(
id: 21,
employeeNo: 'E001',
name: '최승인',
team: '결재팀',
),
}),
);
final authService = _MockAuthService();
when(() => authService.session).thenReturn(
AuthSession(
accessToken: 'test-access',
refreshToken: 'test-refresh',
expiresAt: DateTime(2099, 1, 1),
user: const AuthenticatedUser(
id: 99,
name: '테스트 사용자',
employeeNo: 'E999',
email: 'test@example.com',
),
),
);
GetIt.I.registerSingleton<AuthService>(authService);
});
ApprovalTemplate buildTemplate({bool isActive = true}) {
@@ -333,3 +371,38 @@ void main() {
});
});
}
class _FakeApproverRepository implements ApprovalApproverRepository {
_FakeApproverRepository(this._candidates);
final Map<int, ApprovalApproverCandidate> _candidates;
@override
Future<ApprovalApproverCandidate?> fetchById(int id) async {
return _candidates[id];
}
@override
Future<List<ApprovalApproverCandidate>> search({
required String keyword,
int limit = 20,
}) async {
final lower = keyword.trim().toLowerCase();
if (lower.isEmpty) {
return _candidates.values.toList(growable: false);
}
return _candidates.values
.where(
(candidate) =>
candidate.name.toLowerCase().contains(lower) ||
candidate.employeeNo.toLowerCase().contains(lower) ||
candidate.id.toString().contains(lower),
)
.toList(growable: false);
}
@override
Future<List<ApprovalApproverCandidate>> listInitial({int limit = 20}) async {
return _candidates.values.take(limit).toList(growable: false);
}
}