Files

1591 lines
47 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tdc114plus/src/app.dart';
import 'package:tdc114plus/src/core/actions/contact_launcher.dart';
import 'package:tdc114plus/src/core/network/api_error.dart';
import 'package:tdc114plus/src/features/auth/data/auth_repository.dart';
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
import 'package:tdc114plus/src/features/directory/data/directory_repository.dart';
import 'package:tdc114plus/src/features/directory/data/mock_directory_data.dart';
import 'package:tdc114plus/src/features/directory/domain/employee.dart';
import 'package:tdc114plus/src/features/favorites/data/favorites_repository.dart';
import 'package:tdc114plus/src/features/favorites/domain/favorite_employee.dart';
import 'package:tdc114plus/src/features/organization/data/organization_api_client.dart';
import 'package:tdc114plus/src/features/organization/domain/organization_models.dart';
void main() {
testWidgets('shows link-based login screen', (WidgetTester tester) async {
await _pumpApp(tester);
await tester.pumpAndSettle();
expect(find.text('로그인 링크 발송'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
expect(find.text('로그인 링크 보내기'), findsOneWidget);
});
testWidgets('restores saved session on app launch', (
WidgetTester tester,
) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(hasStoredSession: true),
);
await tester.pumpAndSettle();
expect(find.text('직원검색'), findsWidgets);
expect(find.text('김하늘'), findsOneWidget);
expect(find.text('최민준'), findsOneWidget);
expect(find.text('박서준'), findsNothing);
expect(find.text('이도윤'), findsNothing);
expect(find.text('검색 결과 2명'), findsOneWidget);
});
testWidgets('shows link login guidance with phone input', (
WidgetTester tester,
) async {
await _pumpApp(tester);
expect(find.byType(TextField), findsOneWidget);
expect(
find.textContaining('입력하신 전화번호로 바론SSO 로그인 링크가 발송됩니다.'),
findsNothing,
);
expect(find.textContaining('문자 승인 후 앱으로 돌아오면 자동 로그인됩니다.'), findsOneWidget);
});
testWidgets('keeps login link button visible when keyboard is open', (
WidgetTester tester,
) async {
tester.view.physicalSize = const Size(750, 1334);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester);
await tester.tap(find.byType(TextField));
tester.view.viewInsets = const FakeViewPadding(bottom: 420);
addTearDown(tester.view.resetViewInsets);
await tester.pumpAndSettle();
expect(find.text('로그인 링크 보내기'), findsOneWidget);
expect(tester.getBottomLeft(find.text('로그인 링크 보내기')).dy, lessThan(914));
});
testWidgets('requests login link and shows pending status', (
WidgetTester tester,
) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(pollMode: _FakePollMode.pendingOnly),
);
await tester.enterText(find.byType(TextField), '010-1234-5678');
await tester.tap(find.text('로그인 링크 보내기'));
await tester.pump();
expect(
find.textContaining('문자 링크를 누른 뒤 TDC114PLUS 앱으로 돌아오세요.'),
findsOneWidget,
);
expect(find.text('승인 대기 중'), findsOneWidget);
expect(find.text('다른 번호 입력'), findsOneWidget);
});
testWidgets('restores pending login link and completes polling', (
WidgetTester tester,
) async {
final repository = _FakeAuthRepository(
initialPendingLink: const PhoneLoginLinkInitResponse(
status: 'pending',
pendingRef: 'restored-pending-ref',
expiresIn: 180,
interval: 1,
resendAfter: 0,
),
);
await _pumpApp(tester, repository: repository);
await tester.pumpAndSettle();
expect(repository.polledRefs, contains('restored-pending-ref'));
expect(find.text('직원검색'), findsWidgets);
});
testWidgets('defaults directory scope to logged-in user tenant', (
WidgetTester tester,
) async {
await _openDirectory(tester);
expect(find.text('김하늘'), findsOneWidget);
expect(find.text('최민준'), findsOneWidget);
expect(find.text('박서준'), findsNothing);
expect(find.text('이도윤'), findsNothing);
expect(find.text('정수빈'), findsNothing);
});
testWidgets('keeps initial selection at logged-in pinned team scope', (
WidgetTester tester,
) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(hasStoredSession: true),
directoryRepository: const _OrganizationScopedDirectoryRepository(),
);
await tester.pumpAndSettle();
expect(find.text('Hanmac'), findsNothing);
expect(find.text('김하늘'), findsOneWidget);
expect(find.text('최민준'), findsOneWidget);
expect(find.text('박서준'), findsNothing);
expect(find.text('검색 결과 2명'), findsOneWidget);
});
testWidgets('filters employees by search query', (WidgetTester tester) async {
await _openDirectory(tester);
await tester.enterText(find.byType(TextField), '서준');
await tester.pumpAndSettle();
expect(find.text('박서준'), findsNothing);
expect(find.text('김하늘'), findsNothing);
expect(find.text('검색 결과 0명'), findsOneWidget);
});
testWidgets('allows one hangul character for employee search', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.enterText(find.byType(TextField), '김');
await tester.pumpAndSettle();
expect(find.text('김하늘'), findsOneWidget);
expect(find.text('박서준'), findsNothing);
});
testWidgets('searches employees inside selected company subtree', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.tap(find.text('전체'));
await tester.pumpAndSettle();
await tester.tap(find.text('조직도'));
await tester.pumpAndSettle();
await tester.tap(find.text('한맥').first);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '서준');
await tester.pumpAndSettle();
expect(find.text('박서준'), findsOneWidget);
expect(find.text('김하늘'), findsNothing);
expect(find.text('검색 결과 1명'), findsOneWidget);
});
testWidgets('applies local name filter even when API returns broad results', (
WidgetTester tester,
) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(
hasStoredSession: true,
department: '통합시스템',
),
directoryRepository: const _BroadSearchDirectoryRepository(),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '한');
await tester.pumpAndSettle();
expect(find.text('한승민'), findsOneWidget);
expect(find.text('김윤재'), findsNothing);
expect(find.text('권오재'), findsNothing);
});
testWidgets('keeps current scope list when phone query is under minimum', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.enterText(find.byType(TextField), '010');
await tester.pumpAndSettle();
expect(find.text('전화번호는 010 제외 2자리 이상 입력'), findsOneWidget);
expect(find.text('김하늘'), findsOneWidget);
expect(find.text('박서준'), findsNothing);
expect(find.text('최민준'), findsOneWidget);
expect(find.text('검색 결과 2명'), findsOneWidget);
});
testWidgets('searches only within selected tenant scope', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.tap(find.text('TDC'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '김');
await tester.pumpAndSettle();
expect(find.text('김하늘'), findsNothing);
expect(find.text('검색 결과 0명'), findsOneWidget);
});
testWidgets('filters employees by tenant chip', (WidgetTester tester) async {
await _openDirectory(tester);
await tester.tap(find.text('TDC'));
await tester.pumpAndSettle();
expect(find.text('이도윤'), findsOneWidget);
expect(find.text('정수빈'), findsOneWidget);
expect(find.text('김하늘'), findsNothing);
expect(find.text('검색 결과 2명'), findsOneWidget);
});
testWidgets('starts hierarchy drilldown from all chip', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.tap(find.text('전체'));
await tester.pumpAndSettle();
await tester.tap(find.text('조직도'));
await tester.pumpAndSettle();
expect(find.text('가족사 2개'), findsOneWidget);
expect(find.text('한맥'), findsWidgets);
expect(find.text('TDC'), findsWidgets);
expect(find.text('김하늘'), findsNothing);
});
testWidgets('does not load employees while showing child organizations', (
WidgetTester tester,
) async {
final directoryRepository = _CountingDirectoryRepository();
await _pumpApp(
tester,
repository: _FakeAuthRepository(hasStoredSession: true),
directoryRepository: directoryRepository,
);
await tester.pumpAndSettle();
await tester.tap(find.text('전체'));
await tester.pumpAndSettle();
final callsBeforeHierarchy = directoryRepository.calls;
await tester.tap(find.text('조직도'));
await tester.pumpAndSettle();
expect(find.text('가족사 2개'), findsOneWidget);
expect(directoryRepository.calls, callsBeforeHierarchy);
});
testWidgets(
'keeps pinned team chip visible after selecting another company',
(WidgetTester tester) async {
await _openDirectory(tester);
expect(find.widgetWithText(FilterChip, '기술연구소'), findsWidgets);
await tester.tap(find.text('TDC'));
await tester.pumpAndSettle();
expect(find.widgetWithText(FilterChip, '기술연구소'), findsWidgets);
expect(find.text('이도윤'), findsOneWidget);
expect(find.text('정수빈'), findsOneWidget);
},
);
testWidgets('shows pinned team chip before all chip', (
WidgetTester tester,
) async {
await _openDirectory(tester);
final chips = tester
.widgetList<FilterChip>(find.byType(FilterChip))
.toList();
final labels = chips
.map((chip) => (chip.label as Text).data ?? '')
.where((label) => label.isNotEmpty)
.toList();
final pinnedIndex = labels.indexOf('기술연구소');
final allIndex = labels.indexOf('전체');
expect(pinnedIndex, isNonNegative);
expect(allIndex, isNonNegative);
expect(pinnedIndex, lessThan(allIndex));
});
testWidgets('shows horizontal scrollbar for tenant chips', (
WidgetTester tester,
) async {
await _openDirectory(tester);
expect(find.byType(Scrollbar), findsOneWidget);
});
testWidgets('uses emphasized style for selected tenant chip', (
WidgetTester tester,
) async {
await _openDirectory(tester);
final chip = tester.widget<FilterChip>(
find.widgetWithText(FilterChip, '기술연구소').first,
);
expect(chip.selected, isTrue);
expect(chip.showCheckmark, isFalse);
expect(chip.selectedColor, isNotNull);
});
testWidgets('selects pinned team chip on initial entry when available', (
WidgetTester tester,
) async {
await _openDirectory(tester);
final pinnedChip = tester.widget<FilterChip>(
find.widgetWithText(FilterChip, '기술연구소').first,
);
final companyChip = tester.widget<FilterChip>(
find.widgetWithText(FilterChip, '한맥').first,
);
expect(pinnedChip.selected, isTrue);
expect(companyChip.selected, isFalse);
expect(find.text('김하늘'), findsOneWidget);
expect(find.text('최민준'), findsOneWidget);
expect(find.text('박서준'), findsNothing);
expect(find.text('검색 결과 2명'), findsOneWidget);
});
testWidgets(
'resolves initial team from org chart when session scope is empty',
(WidgetTester tester) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(
hasStoredSession: true,
tenantSlug: '',
department: '',
),
);
await tester.pumpAndSettle();
final pinnedChip = tester.widget<FilterChip>(
find.widgetWithText(FilterChip, '기술연구소').first,
);
final allChip = tester.widget<FilterChip>(
find.widgetWithText(FilterChip, '전체').first,
);
expect(pinnedChip.selected, isTrue);
expect(allChip.selected, isFalse);
expect(find.text('김하늘'), findsOneWidget);
expect(find.text('최민준'), findsOneWidget);
expect(find.text('박서준'), findsNothing);
expect(find.text('검색 결과 2명'), findsOneWidget);
},
);
testWidgets(
'shows synthetic pinned team chip before all chip when tenant node is missing',
(WidgetTester tester) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(
hasStoredSession: true,
department: 'IS3',
),
);
await tester.pumpAndSettle();
final chips = tester
.widgetList<FilterChip>(find.byType(FilterChip))
.toList();
final labels = chips
.map((chip) => (chip.label as Text).data ?? '')
.where((label) => label.isNotEmpty)
.toList();
final pinnedIndex = labels.indexOf('IS3');
final allIndex = labels.indexOf('전체');
expect(pinnedIndex, isNonNegative);
expect(allIndex, isNonNegative);
expect(pinnedIndex, lessThan(allIndex));
},
);
testWidgets(
'does not duplicate affiliation text when tenant and department match',
(WidgetTester tester) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(
hasStoredSession: true,
department: 'IS3',
),
directoryRepository: const _DuplicateAffiliationDirectoryRepository(),
);
await tester.pumpAndSettle();
expect(find.text('IS3 · +821012345678'), findsOneWidget);
expect(find.text('IS3 · IS3 · +821012345678'), findsNothing);
},
);
testWidgets('drills down from all to company to department to employees', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.tap(find.text('전체'));
await tester.pumpAndSettle();
await tester.tap(find.text('조직도'));
await tester.pumpAndSettle();
await tester.tap(find.text('한맥').first);
await tester.pumpAndSettle();
expect(find.text('ICT사업부'), findsWidgets);
expect(find.text('기술연구소'), findsWidgets);
await tester.tap(find.text('기술연구소').last);
await tester.pumpAndSettle();
expect(find.text('김하늘'), findsOneWidget);
expect(find.text('최민준'), findsOneWidget);
expect(find.text('박서준'), findsNothing);
});
testWidgets('shows direct employees for non-leaf organization separately', (
WidgetTester tester,
) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(hasStoredSession: true),
directoryRepository: const _DirectAffiliationDirectoryRepository(),
);
await tester.pumpAndSettle();
await tester.tap(find.text('전체'));
await tester.pumpAndSettle();
await tester.tap(find.text('조직도'));
await tester.pumpAndSettle();
await tester.tap(find.text('한맥').first);
await tester.pumpAndSettle();
expect(find.text('한맥 조직 관리자'), findsOneWidget);
expect(find.text('이영희'), findsOneWidget);
expect(find.text('김하늘'), findsNothing);
expect(
tester.getTopLeft(find.text('한맥 조직 관리자')).dy,
lessThan(tester.getTopLeft(find.text('ICT사업부').last).dy),
);
});
testWidgets(
'excludes root employees that also belong to child organizations from direct employees',
(WidgetTester tester) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(hasStoredSession: true),
directoryRepository: const _OverlappingMembershipDirectoryRepository(),
);
await tester.pumpAndSettle();
await tester.tap(find.text('전체'));
await tester.pumpAndSettle();
await tester.tap(find.text('조직도'));
await tester.pumpAndSettle();
await tester.tap(find.text('한맥').first);
await tester.pumpAndSettle();
expect(find.text('한맥 조직 관리자'), findsOneWidget);
expect(find.text('이영희'), findsOneWidget);
expect(find.text('김하늘'), findsNothing);
expect(find.text('박서준'), findsNothing);
},
);
testWidgets('shows divider between top chips and breadcrumb chips', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.tap(find.text('전체'));
await tester.pumpAndSettle();
await tester.tap(find.text('조직도'));
await tester.pumpAndSettle();
await tester.tap(find.text('한맥').first);
await tester.pumpAndSettle();
expect(find.byType(Divider), findsAtLeastNWidgets(1));
});
testWidgets('shows emphasized icon on selected breadcrumb chip', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.tap(find.text('전체'));
await tester.pumpAndSettle();
await tester.tap(find.text('조직도'));
await tester.pumpAndSettle();
await tester.tap(find.text('한맥').first);
await tester.pumpAndSettle();
await tester.tap(find.text('기술연구소').last);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.check_circle), findsOneWidget);
});
testWidgets('sorts organization employees by manager then grade then name', (
WidgetTester tester,
) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(hasStoredSession: true),
directoryRepository: const _OrganizationSortingDirectoryRepository(),
);
await tester.pumpAndSettle();
await tester.tap(find.text('기술연구소').last);
await tester.pumpAndSettle();
await tester.tap(find.text('조직도'));
await tester.pumpAndSettle();
expect(find.text('인프라 BIM3'), findsOneWidget);
expect(find.text('미지정'), findsOneWidget);
final leaderY = tester.getTopLeft(find.text('김지영')).dy;
final directorY = tester.getTopLeft(find.text('박도현')).dy;
final principalY = tester.getTopLeft(find.text('김성엽')).dy;
final seniorY = tester.getTopLeft(find.text('한반석')).dy;
final staffY = tester.getTopLeft(find.text('유서진')).dy;
expect(leaderY, lessThan(principalY));
expect(principalY, lessThan(directorY));
expect(directorY, lessThan(seniorY));
expect(seniorY, lessThan(staffY));
});
testWidgets('returns to employee list after switching organization then employees', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.tap(find.text('전체'));
await tester.pumpAndSettle();
await tester.tap(find.text('조직도'));
await tester.pumpAndSettle();
expect(find.text('가족사 2개'), findsOneWidget);
expect(find.text('한맥'), findsWidgets);
await tester.tap(find.text('직원'));
await tester.pumpAndSettle();
expect(find.text('김하늘'), findsOneWidget);
expect(find.byType(Card), findsWidgets);
});
testWidgets('shows favorite employees view', (WidgetTester tester) async {
await _openDirectory(tester);
await tester.tap(find.text('즐겨찾기'));
await tester.pumpAndSettle();
expect(find.text('김하늘'), findsOneWidget);
expect(find.text('박서준'), findsNothing);
expect(find.text('검색 결과 1명'), findsOneWidget);
});
testWidgets('toggles favorite employees', (WidgetTester tester) async {
await _openDirectory(tester);
await tester.tap(find.byTooltip('즐겨찾기 추가').first);
await tester.pumpAndSettle();
await tester.tap(find.text('즐겨찾기'));
await tester.pumpAndSettle();
expect(find.text('김하늘'), findsOneWidget);
expect(find.text('최민준'), findsOneWidget);
expect(find.text('검색 결과 2명'), findsOneWidget);
});
testWidgets('opens employee detail bottom sheet', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.tap(find.text('김하늘'));
await tester.pumpAndSettle();
expect(find.text('haneul.kim@example.com'), findsOneWidget);
expect(find.text('전화'), findsOneWidget);
expect(find.text('문자'), findsOneWidget);
});
testWidgets('loads enriched employee detail in bottom sheet', (
WidgetTester tester,
) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(hasStoredSession: true),
employeeDetailRepository: const _EnrichedEmployeeDetailRepository(),
);
await tester.tap(find.text('김하늘'));
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('kim.haneul@corp.example.com'), findsOneWidget);
expect(find.text('보안 아키텍트'), findsOneWidget);
expect(find.text('한맥, 기술연구소'), findsOneWidget);
});
testWidgets('falls back to list data when employee detail load fails', (
WidgetTester tester,
) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(hasStoredSession: true),
employeeDetailRepository: const _FailingEmployeeDetailRepository(),
);
await tester.tap(find.text('김하늘'));
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('haneul.kim@example.com'), findsOneWidget);
expect(find.text('Flutter 개발'), findsOneWidget);
});
testWidgets('disables contact actions when employee has no phone number', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.enterText(find.byType(TextField), '최민준');
await tester.pumpAndSettle();
await tester.tap(find.text('최민준').last);
await tester.pumpAndSettle();
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, '전화'))
.onPressed,
isNull,
);
expect(
tester
.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, '문자'))
.onPressed,
isNull,
);
});
testWidgets('honors employee detail action permissions', (
WidgetTester tester,
) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(hasStoredSession: true),
employeeDetailRepository: const _ActionRestrictedEmployeeDetailRepository(),
);
await tester.tap(find.text('김하늘'));
await tester.pump();
await tester.pumpAndSettle();
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, '전화'))
.onPressed,
isNull,
);
expect(
tester
.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, '문자'))
.onPressed,
isNotNull,
);
});
testWidgets('toggles favorite from employee detail bottom sheet', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.enterText(find.byType(TextField), '최민준');
await tester.pumpAndSettle();
await tester.tap(find.text('최민준').last);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('즐겨찾기 추가').last);
await tester.pumpAndSettle();
await tester.tap(find.text('즐겨찾기'));
await tester.pumpAndSettle();
expect(find.text('최민준').last, findsOneWidget);
});
testWidgets('routes call and sms actions through contact launcher', (
WidgetTester tester,
) async {
final launcher = _FakeContactLauncher();
await _pumpApp(
tester,
repository: _FakeAuthRepository(hasStoredSession: true),
launcher: launcher,
);
await tester.pumpAndSettle();
await tester.tap(find.text('김하늘'));
await tester.pumpAndSettle();
await tester.tap(find.text('전화'));
await tester.pumpAndSettle();
await tester.tap(find.text('문자'));
await tester.pumpAndSettle();
expect(launcher.calledNumbers, ['call:+821012345678', 'sms:+821012345678']);
});
testWidgets('returns to login when directory API requires relogin', (
WidgetTester tester,
) async {
final repository = _FakeAuthRepository(hasStoredSession: true);
await _pumpApp(
tester,
repository: repository,
directoryRepository: const _UnauthorizedDirectoryRepository(),
);
await tester.pumpAndSettle();
expect(find.text('다시 로그인'), findsOneWidget);
await tester.tap(find.text('다시 로그인'));
await tester.pumpAndSettle();
expect(repository.logoutCalled, isTrue);
expect(find.text('로그인 링크 발송'), findsOneWidget);
});
testWidgets('logs out from directory app bar', (WidgetTester tester) async {
final repository = _FakeAuthRepository(hasStoredSession: true);
await _pumpApp(tester, repository: repository);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('로그아웃'));
await tester.pumpAndSettle();
expect(repository.logoutCalled, isTrue);
expect(find.text('로그인 링크 발송'), findsOneWidget);
});
}
Future<void> _openDirectory(WidgetTester tester) async {
await _pumpApp(
tester,
repository: _FakeAuthRepository(hasStoredSession: true),
);
await tester.pumpAndSettle();
if (find.text('직원검색').evaluate().isNotEmpty) {
return;
}
}
Future<void> _pumpApp(
WidgetTester tester, {
AuthRepository? repository,
DirectoryRepository? directoryRepository,
EmployeeDetailRepository? employeeDetailRepository,
OrganizationRepository? organizationRepository,
ContactLauncher? launcher,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
authRepositoryProvider.overrideWithValue(
repository ?? _FakeAuthRepository(),
),
directoryRepositoryProvider.overrideWithValue(
directoryRepository ?? const _FakeDirectoryRepository(),
),
employeeDetailRepositoryProvider.overrideWithValue(
employeeDetailRepository ?? const _FakeEmployeeDetailRepository(),
),
organizationRepositoryProvider.overrideWithValue(
organizationRepository ?? const _FakeOrganizationRepository(),
),
favoritesRepositoryProvider.overrideWithValue(
_FakeFavoritesRepository(),
),
contactLauncherProvider.overrideWithValue(
launcher ?? _FakeContactLauncher(),
),
],
child: const Tdc114PlusApp(),
),
);
await tester.pumpAndSettle();
}
class _FakeAuthRepository implements AuthRepository {
_FakeAuthRepository({
this.hasStoredSession = false,
this.tenantSlug = 'hanmac',
this.department = '기술연구소',
this.pollMode = _FakePollMode.completeImmediately,
this.initialPendingLink,
});
final bool hasStoredSession;
final String tenantSlug;
final String department;
final _FakePollMode pollMode;
final PhoneLoginLinkInitResponse? initialPendingLink;
StoredAuthSession? _storedSession;
PhoneLoginLinkInitResponse? _pendingLink;
var _pendingCleared = false;
final List<String> polledRefs = [];
var logoutCalled = false;
Future<PhoneLoginResponse> phoneLogin(String phoneNumber) async {
final response = PhoneLoginResponse(
status: 'ok',
token: 'session-token',
expiresAt: DateTime.parse('2026-07-02T12:00:00Z'),
user: LoginUser(
id: 'user-uuid',
name: 'User One',
phoneNumber: '+821012345678',
tenantId: 'tenant-uuid',
tenantName: 'Hanmac',
tenantSlug: tenantSlug,
department: department,
),
);
_storedSession = StoredAuthSession(
token: response.token,
expiresAt: response.expiresAt,
user: response.user,
);
return response;
}
@override
Future<PhoneLoginLinkInitResponse> requestPhoneLoginLink(
String phoneNumber,
) async {
_pendingCleared = false;
_pendingLink = const PhoneLoginLinkInitResponse(
status: 'pending',
pendingRef: 'pending-ref',
expiresIn: 180,
interval: 1,
resendAfter: 30,
);
return _pendingLink!;
}
@override
Future<PhoneLoginLinkPollResponse> pollPhoneLoginLink(
String pendingRef,
) async {
polledRefs.add(pendingRef);
if (pollMode == _FakePollMode.pendingOnly) {
return const PhoneLoginLinkPollResponse(
status: 'pending',
code: 'authorization_pending',
interval: 1,
);
}
return PhoneLoginLinkPollResponse(
status: 'ok',
session: await phoneLogin('01012345678'),
);
}
@override
Future<PhoneLoginLinkInitResponse?> loadPendingPhoneLoginLink() async {
if (_pendingCleared) {
return null;
}
return _pendingLink ?? initialPendingLink;
}
@override
Future<void> clearPendingPhoneLoginLink() async {
_pendingLink = null;
_pendingCleared = true;
}
@override
Future<StoredAuthSession?> loadSession() async {
if (_storedSession != null) {
return _storedSession;
}
if (!hasStoredSession) {
return null;
}
return StoredAuthSession(
token: 'session-token',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
user: LoginUser(
id: 'user-uuid',
name: 'User One',
phoneNumber: '+821012345678',
tenantId: 'tenant-uuid',
tenantName: 'Hanmac',
tenantSlug: tenantSlug,
department: department,
),
);
}
@override
Future<void> logout() async {
logoutCalled = true;
_pendingLink = null;
_pendingCleared = true;
}
}
enum _FakePollMode { completeImmediately, pendingOnly }
class _FakeDirectoryRepository implements DirectoryRepository {
const _FakeDirectoryRepository();
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
return _filteredEmployees(query);
}
List<Employee> _filteredEmployees(DirectoryQuery query) {
final normalizedQuery = query.query.replaceAll('-', '').toLowerCase();
final selectedTenant = _findTenantBySlug(query.tenantSlug);
final tenantSlugs = query.apiTenantSlugs;
return mockEmployees.where((employee) {
final matchesTenant =
tenantSlugs == null ||
tenantSlugs.contains(employee.tenantSlug) ||
(selectedTenant != null &&
selectedTenant.type != 'COMPANY' &&
(employee.department ?? '') == selectedTenant.name);
final matchesDepartment =
query.department == null || employee.department == query.department;
final phone = employee.phoneNumber.replaceAll(RegExp(r'[^0-9]'), '');
final phoneDisplay = employee.phoneDisplay?.replaceAll('-', '') ?? '';
final searchable = [
employee.name,
employee.phoneNumber,
employee.phoneDisplay ?? '',
employee.email ?? '',
employee.tenantName,
employee.department ?? '',
employee.grade ?? '',
employee.position ?? '',
employee.jobTitle ?? '',
phone,
phoneDisplay,
].join(' ').toLowerCase();
final matchesQuery =
normalizedQuery.isEmpty || searchable.contains(normalizedQuery);
return matchesTenant && matchesDepartment && matchesQuery;
}).toList();
}
TenantSummary? _findTenantBySlug(String slug) {
for (final tenant in mockTenants) {
if (tenant.slug == slug) {
return tenant;
}
}
return null;
}
}
class _FakeEmployeeDetailRepository implements EmployeeDetailRepository {
const _FakeEmployeeDetailRepository();
@override
Future<EmployeeDetail> loadEmployeeDetail(String employeeId) async {
final employee = mockEmployees.firstWhere((item) => item.id == employeeId);
return EmployeeDetail(
id: employee.id,
name: employee.name,
phoneNumber: employee.phoneNumber,
phoneDisplay: employee.phoneDisplay,
email: employee.email,
tenantId: employee.tenantId,
tenantName: employee.tenantName,
tenantSlug: employee.tenantSlug,
department: employee.department,
grade: employee.grade,
position: employee.position,
jobTitle: employee.jobTitle,
status: employee.status,
profileImageUrl: employee.profileImageUrl,
sortOrder: employee.sortOrder,
isManager: employee.isManager,
joinedTenants: [
TenantRef(
id: employee.tenantId,
name: employee.tenantName,
slug: employee.tenantSlug,
type: 'COMPANY',
),
],
actions: const EmployeeActions(call: true, sms: true, email: true),
);
}
}
class _EnrichedEmployeeDetailRepository implements EmployeeDetailRepository {
const _EnrichedEmployeeDetailRepository();
@override
Future<EmployeeDetail> loadEmployeeDetail(String employeeId) async {
return const EmployeeDetail(
id: 'user-001',
name: '김하늘',
phoneNumber: '+821012345678',
phoneDisplay: '010-1234-5678',
email: 'kim.haneul@corp.example.com',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
department: '기술연구소',
grade: '책임',
position: '팀장',
jobTitle: '보안 아키텍트',
status: 'active',
joinedTenants: [
TenantRef(
id: 'tenant-hanmac',
name: '한맥',
slug: 'hanmac',
type: 'COMPANY',
),
TenantRef(
id: 'tenant-rnd',
name: '기술연구소',
slug: 'rnd',
type: 'ORGANIZATION',
parentId: 'tenant-hanmac',
),
],
actions: EmployeeActions(call: true, sms: true, email: true),
);
}
}
class _FailingEmployeeDetailRepository implements EmployeeDetailRepository {
const _FailingEmployeeDetailRepository();
@override
Future<EmployeeDetail> loadEmployeeDetail(String employeeId) async {
throw const ApiException(
statusCode: 500,
apiError: ApiError(
error: 'detail_failed',
code: 'detail_failed',
details: {},
),
);
}
}
class _ActionRestrictedEmployeeDetailRepository
implements EmployeeDetailRepository {
const _ActionRestrictedEmployeeDetailRepository();
@override
Future<EmployeeDetail> loadEmployeeDetail(String employeeId) async {
return const EmployeeDetail(
id: 'user-001',
name: '김하늘',
phoneNumber: '+821012345678',
phoneDisplay: '010-1234-5678',
email: 'haneul.kim@example.com',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
department: '기술연구소',
grade: '책임',
position: '팀장',
jobTitle: 'Flutter 개발',
status: 'active',
joinedTenants: [],
actions: EmployeeActions(call: false, sms: true, email: true),
);
}
}
class _CountingDirectoryRepository implements DirectoryRepository {
var calls = 0;
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
calls += 1;
return const _FakeDirectoryRepository().loadEmployees(query);
}
}
class _FakeOrganizationRepository implements OrganizationRepository {
const _FakeOrganizationRepository();
@override
Future<OrgChartSnapshot> getOrgChart({
String? tenantId,
bool refresh = false,
}) async {
return OrgChartSnapshot(
tenants: mockTenants,
employees: mockEmployees,
generatedAt: DateTime.parse('2026-07-02T00:00:00Z'),
);
}
@override
Future<TenantListResponse> listTenants() async {
return TenantListResponse(
items: mockTenants,
generatedAt: DateTime.parse('2026-07-02T00:00:00Z'),
);
}
}
class _FakeFavoritesRepository implements FavoritesRepository {
_FakeFavoritesRepository()
: _favorites = [
FavoriteEmployee(
employeeId: 'user-001',
createdAt: DateTime.parse('2026-07-02T00:00:00Z'),
),
];
final List<FavoriteEmployee> _favorites;
@override
Future<List<FavoriteEmployee>> loadFavorites() async {
return List.unmodifiable(_favorites);
}
@override
Future<void> toggleFavorite(String employeeId) async {
final existingIndex = _favorites.indexWhere(
(favorite) => favorite.employeeId == employeeId,
);
if (existingIndex >= 0) {
_favorites.removeAt(existingIndex);
return;
}
_favorites.add(
FavoriteEmployee(employeeId: employeeId, createdAt: DateTime.now()),
);
}
}
class _UnauthorizedDirectoryRepository implements DirectoryRepository {
const _UnauthorizedDirectoryRepository();
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
throw const ApiException(
statusCode: 401,
apiError: ApiError(
error: 'unauthorized',
code: 'unauthorized',
details: {},
),
);
}
}
class _DuplicateAffiliationDirectoryRepository implements DirectoryRepository {
const _DuplicateAffiliationDirectoryRepository();
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
return const [
Employee(
id: 'is3-user',
name: '송대일',
phoneNumber: '+821012345678',
phoneDisplay: '+821012345678',
tenantId: 'tenant-is3',
tenantName: 'IS3',
tenantSlug: 'is3',
department: 'IS3',
),
];
}
}
class _OrganizationScopedDirectoryRepository implements DirectoryRepository {
const _OrganizationScopedDirectoryRepository();
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
if (query.tenantSlug == 'hanmac' && query.query == '+821012345678') {
return const [
Employee(
id: 'user-002',
name: '박서준',
phoneNumber: '+821012345678',
phoneDisplay: '010-1234-5678',
email: 'seojun.park@example.com',
tenantId: 'tenant-ict',
tenantName: 'ICT사업부',
tenantSlug: 'ict',
department: 'ICT사업부',
position: '매니저',
),
];
}
if (query.tenantSlug == 'ict') {
return const [
Employee(
id: 'user-002',
name: '박서준',
phoneNumber: '+821023456789',
phoneDisplay: '010-2345-6789',
email: 'seojun.park@example.com',
tenantId: 'tenant-ict',
tenantName: 'ICT사업부',
tenantSlug: 'ict',
department: 'ICT사업부',
grade: '선임',
position: '매니저',
),
];
}
if (query.tenantSlug == 'rnd') {
return const [
Employee(
id: 'user-001',
name: '김하늘',
phoneNumber: '+821012345678',
phoneDisplay: '010-1234-5678',
email: 'haneul.kim@example.com',
tenantId: 'tenant-rnd',
tenantName: '기술연구소',
tenantSlug: 'rnd',
department: '기술연구소',
grade: '책임',
position: '팀장',
),
Employee(
id: 'user-005',
name: '최민준',
phoneNumber: '',
phoneDisplay: null,
email: 'minjun.choi@example.com',
tenantId: 'tenant-rnd',
tenantName: '기술연구소',
tenantSlug: 'rnd',
department: '기술연구소',
grade: '수석',
position: '연구원',
),
];
}
if (query.tenantSlug == 'ict' && query.query.isEmpty) {
return const [
Employee(
id: 'user-002',
name: '박서준',
phoneNumber: '+821023456789',
phoneDisplay: '010-2345-6789',
email: 'seojun.park@example.com',
tenantId: 'tenant-ict',
tenantName: 'ICT사업부',
tenantSlug: 'ict',
department: 'ICT사업부',
grade: '선임',
position: '매니저',
jobTitle: 'API 연동',
status: 'active',
sortOrder: 20,
),
];
}
return const _FakeDirectoryRepository().loadEmployees(query);
}
}
class _OrganizationSortingDirectoryRepository implements DirectoryRepository {
const _OrganizationSortingDirectoryRepository();
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
if (query.tenantSlug != 'rnd') {
return const _FakeDirectoryRepository().loadEmployees(query);
}
return const [
Employee(
id: 'user-b',
name: '유서진',
phoneNumber: '+821011111113',
phoneDisplay: '010-1111-1113',
email: 'user3@example.com',
tenantId: 'tenant-infra-bim3',
tenantName: '인프라 BIM3',
tenantSlug: 'rnd',
department: '미지정',
grade: '사원',
position: '연구원',
),
Employee(
id: 'user-director',
name: '박도현',
phoneNumber: '+821011111115',
phoneDisplay: '010-1111-1115',
email: 'director@example.com',
tenantId: 'tenant-infra-bim3',
tenantName: '인프라 BIM3',
tenantSlug: 'rnd',
department: '미지정',
grade: '이사',
position: '임원',
),
Employee(
id: 'user-a',
name: '김성엽',
phoneNumber: '+821011111112',
phoneDisplay: '010-1111-1112',
email: 'user2@example.com',
tenantId: 'tenant-infra-bim3',
tenantName: '인프라 BIM3',
tenantSlug: 'rnd',
department: '미지정',
grade: '수석연구원',
position: '연구원',
),
Employee(
id: 'user-leader',
name: '김지영',
phoneNumber: '+821011111111',
phoneDisplay: '010-1111-1111',
email: 'leader@example.com',
tenantId: 'tenant-infra-bim3',
tenantName: '인프라 BIM3',
tenantSlug: 'rnd',
department: '미지정',
grade: '책임연구원',
position: '팀장',
isManager: true,
),
Employee(
id: 'user-c',
name: '한반석',
phoneNumber: '+821011111114',
phoneDisplay: '010-1111-1114',
email: 'user4@example.com',
tenantId: 'tenant-infra-bim3',
tenantName: '인프라 BIM3',
tenantSlug: 'rnd',
department: '미지정',
grade: '선임연구원',
position: '연구원',
),
];
}
}
class _DirectAffiliationDirectoryRepository implements DirectoryRepository {
const _DirectAffiliationDirectoryRepository();
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
if (query.tenantSlug != 'hanmac') {
return const _FakeDirectoryRepository().loadEmployees(query);
}
return const [
Employee(
id: 'direct-manager',
name: '이영희',
phoneNumber: '+821011112222',
phoneDisplay: '010-1111-2222',
email: 'younghee.lee@example.com',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
grade: '이사',
position: '디비전장',
isManager: true,
),
Employee(
id: 'child-member',
name: '김하늘',
phoneNumber: '+821012345678',
phoneDisplay: '010-1234-5678',
email: 'haneul.kim@example.com',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
department: '기술연구소',
grade: '책임',
position: '팀장',
),
];
}
}
class _OverlappingMembershipDirectoryRepository implements DirectoryRepository {
const _OverlappingMembershipDirectoryRepository();
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
if (query.tenantSlug != 'hanmac') {
return const _FakeDirectoryRepository().loadEmployees(query);
}
return const [
Employee(
id: 'root-only-exec',
name: '이영희',
phoneNumber: '+821011112222',
phoneDisplay: '010-1111-2222',
email: 'younghee.lee@example.com',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
grade: '이사',
position: '디비전장',
isManager: true,
),
Employee(
id: 'child-member-1',
name: '김하늘',
phoneNumber: '+821012345678',
phoneDisplay: '010-1234-5678',
email: 'haneul.kim@example.com',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
grade: '책임',
position: '팀장',
),
Employee(
id: 'child-member-1',
name: '김하늘',
phoneNumber: '+821012345678',
phoneDisplay: '010-1234-5678',
email: 'haneul.kim@example.com',
tenantId: 'tenant-rnd',
tenantName: '한맥',
tenantSlug: 'rnd',
department: '기술연구소',
grade: '책임',
position: '팀장',
),
Employee(
id: 'child-member-2',
name: '박서준',
phoneNumber: '+821023456789',
phoneDisplay: '010-2345-6789',
email: 'seojun.park@example.com',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
grade: '선임',
position: '매니저',
),
Employee(
id: 'child-member-2',
name: '박서준',
phoneNumber: '+821023456789',
phoneDisplay: '010-2345-6789',
email: 'seojun.park@example.com',
tenantId: 'tenant-ict',
tenantName: '한맥',
tenantSlug: 'ict',
department: 'ICT사업부',
grade: '선임',
position: '매니저',
),
];
}
}
class _BroadSearchDirectoryRepository implements DirectoryRepository {
const _BroadSearchDirectoryRepository();
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
if (query.tenantSlug != 'hanmac' && query.apiDepartment != '통합시스템') {
return const _FakeDirectoryRepository().loadEmployees(query);
}
return const [
Employee(
id: 'broad-001',
name: '김윤재',
phoneNumber: '+821097479838',
phoneDisplay: '010-9747-9838',
email: 'yoonjae.kim@example.com',
tenantId: 'tenant-hanmac',
tenantName: '총괄기획&기술개발센터',
tenantSlug: 'hanmac',
department: '통합시스템',
position: 'IS1',
),
Employee(
id: 'broad-002',
name: '한승민',
phoneNumber: '+821031891514',
phoneDisplay: '010-3189-1514',
email: 'seungmin.han@example.com',
tenantId: 'tenant-hanmac',
tenantName: '총괄기획&기술개발센터',
tenantSlug: 'hanmac',
department: '통합시스템',
position: 'IS1',
),
Employee(
id: 'broad-003',
name: '권오재',
phoneNumber: '+821091143943',
phoneDisplay: '010-9114-3943',
email: 'ojae.kwon@example.com',
tenantId: 'tenant-hanmac',
tenantName: '총괄기획&기술개발센터',
tenantSlug: 'hanmac',
department: '통합시스템',
position: 'IS2',
),
];
}
}
class _FakeContactLauncher implements ContactLauncher {
_FakeContactLauncher();
final List<String> calledNumbers = [];
@override
Future<bool> call(String phoneNumber) async {
calledNumbers.add('call:$phoneNumber');
return true;
}
@override
Future<bool> sms(String phoneNumber) async {
calledNumbers.add('sms:$phoneNumber');
return true;
}
}