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(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( 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( find.widgetWithText(FilterChip, '기술연구소').first, ); final companyChip = tester.widget( 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( find.widgetWithText(FilterChip, '기술연구소').first, ); final allChip = tester.widget( 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(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(find.widgetWithText(FilledButton, '전화')) .onPressed, isNull, ); expect( tester .widget(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(find.widgetWithText(FilledButton, '전화')) .onPressed, isNull, ); expect( tester .widget(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 _openDirectory(WidgetTester tester) async { await _pumpApp( tester, repository: _FakeAuthRepository(hasStoredSession: true), ); await tester.pumpAndSettle(); if (find.text('직원검색').evaluate().isNotEmpty) { return; } } Future _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 polledRefs = []; var logoutCalled = false; Future 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 requestPhoneLoginLink( String phoneNumber, ) async { _pendingCleared = false; _pendingLink = const PhoneLoginLinkInitResponse( status: 'pending', pendingRef: 'pending-ref', expiresIn: 180, interval: 1, resendAfter: 30, ); return _pendingLink!; } @override Future 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 loadPendingPhoneLoginLink() async { if (_pendingCleared) { return null; } return _pendingLink ?? initialPendingLink; } @override Future clearPendingPhoneLoginLink() async { _pendingLink = null; _pendingCleared = true; } @override Future 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 logout() async { logoutCalled = true; _pendingLink = null; _pendingCleared = true; } } enum _FakePollMode { completeImmediately, pendingOnly } class _FakeDirectoryRepository implements DirectoryRepository { const _FakeDirectoryRepository(); @override Future> loadEmployees(DirectoryQuery query) async { return _filteredEmployees(query); } List _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 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 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 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 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> loadEmployees(DirectoryQuery query) async { calls += 1; return const _FakeDirectoryRepository().loadEmployees(query); } } class _FakeOrganizationRepository implements OrganizationRepository { const _FakeOrganizationRepository(); @override Future getOrgChart({ String? tenantId, bool refresh = false, }) async { return OrgChartSnapshot( tenants: mockTenants, employees: mockEmployees, generatedAt: DateTime.parse('2026-07-02T00:00:00Z'), ); } @override Future 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 _favorites; @override Future> loadFavorites() async { return List.unmodifiable(_favorites); } @override Future 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> loadEmployees(DirectoryQuery query) async { throw const ApiException( statusCode: 401, apiError: ApiError( error: 'unauthorized', code: 'unauthorized', details: {}, ), ); } } class _DuplicateAffiliationDirectoryRepository implements DirectoryRepository { const _DuplicateAffiliationDirectoryRepository(); @override Future> 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> 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> 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> 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> 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> 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 calledNumbers = []; @override Future call(String phoneNumber) async { calledNumbers.add('call:$phoneNumber'); return true; } @override Future sms(String phoneNumber) async { calledNumbers.add('sms:$phoneNumber'); return true; } }