diff --git a/docs/test-plan.md b/docs/test-plan.md index 6b1599a9..5c6ab8f9 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -28,6 +28,7 @@ - Backend 테스트 전수 목록: `docs/test-plan/backend-test-inventory.md` - UserFront 테스트 전수 목록: `docs/test-plan/userfront-test-inventory.md` - AdminFront/DevFront E2E 전수 목록: `docs/test-plan/web-e2e-test-inventory.md` +- UserFront WASM Playwright E2E 확장 계획: `docs/test-plan/userfront-wasm-e2e-expansion-plan.md` ## 4) 실행 커맨드 - Backend 전체 테스트: `cd backend && go test ./...` @@ -35,6 +36,7 @@ - UserFront 테스트: `cd userfront && flutter test` - AdminFront E2E: `cd adminfront && npm test` - DevFront E2E: `cd devfront && npm test` +- UserFront WASM E2E(계획): `docs/test-plan/userfront-wasm-e2e-expansion-plan.md` 기준으로 Playwright 워크스페이스를 추가한 뒤 실행 ## 5) 유지 원칙 - 신규 기능은 관련 테스트를 반드시 추가합니다. diff --git a/docs/test-plan/userfront-wasm-e2e-expansion-plan.md b/docs/test-plan/userfront-wasm-e2e-expansion-plan.md new file mode 100644 index 00000000..26c8d90a --- /dev/null +++ b/docs/test-plan/userfront-wasm-e2e-expansion-plan.md @@ -0,0 +1,69 @@ +# UserFront WASM Playwright E2E 확장 계획 + +- 작성일: 2026-02-23 +- 대상: `userfront` (Flutter Web WASM 산출물) +- 목적: 로그인/리다이렉트/QR 흐름의 브라우저 실동작 회귀를 CI에서 자동 검증 + +## 1) 전제 +- `flutter build web --wasm --release` 산출물(`userfront/build/web`)을 정적 서버로 서빙합니다. +- Playwright는 해당 URL로 접속해 E2E를 수행합니다. +- 카메라/QR은 실장비 의존도를 제거하기 위해 브라우저 API mock 기반 케이스를 기본으로 구성합니다. + +## 2) 확장 범위 (우선순위) +1. Locale 진입/리다이렉트 +- `/` 진입 시 `/{locale}`로 이동 +- 비로그인 상태 `/{locale}` 진입 시 `/{locale}/signin` 이동 +- 로그인 상태 `/{locale}` 진입 시 `/{locale}/dashboard` 이동 + +2. 로그인 성공/실패 및 새로고침 회귀 +- 정상 로그인 후 `/{locale}/dashboard` 진입 +- 대시보드 진입 후 새로고침 시 `signin`으로 튕기지 않음 +- 비밀번호 오류 시 코드 기반 에러 표시 동작 확인 + +3. 비밀번호 재설정 플로우 +- reset 링크 진입 후 비밀번호 변경 +- 변경된 비밀번호로 즉시 로그인 가능 + +4. QR 로그인 (웹 로그인 페이지) +- QR init/poll 기본 플로우 +- 만료/재발급 동작 + +5. QR 스캔/승인 (WASM) +- `/scan`에서 스캔 결과가 `/{locale}/approve?ref=...`로 전달됨 +- BarcodeDetector 미지원/카메라 실패 시 수동 입력 fallback 동작 +- approve 성공 시 dashboard 이동 + +6. 널체크 회복 경로 회귀 +- `/ko` 경로에서 null-check 예외 발생 시 recovery target(`/{locale}/signin`) 이동 보장 + +## 3) 구현 단계 +### Phase 0. E2E 실행 기반 +- `userfront-e2e/` (Playwright) 추가 +- `BASE_URL`/`LOCALE`/`MOCK_AUTH` 환경변수 표준화 +- CI job: WASM build 산출물 서빙 + Playwright 실행 + +### Phase 1. 인증/리다이렉트 핵심 회귀 +- 범위 1~2 구현 +- 실패 재현 케이스를 먼저 작성(Failing test first) + +### Phase 2. 비밀번호 재설정 회귀 +- 범위 3 구현 +- 성공/실패 케이스 분리 + +### Phase 3. QR 흐름 회귀 +- 범위 4~5 구현 +- BarcodeDetector/getUserMedia mock fixture 도입 + +### Phase 4. 에러/회복 회귀 +- 범위 6 구현 +- null-check 복구 라우팅 검증 + +## 4) 완료 기준 +- 핵심 인증 플로우(로그인/새로고침/리다이렉트/QR)가 Playwright 회귀군으로 자동화됩니다. +- 프로덕션 이슈 재발 건은 재현 테스트가 먼저 추가됩니다. +- PR에서 E2E 결과 링크(성공/실패 로그) 확인이 가능합니다. + +## 5) 운영 원칙 +- 버그는 반드시 재현 테스트를 먼저 추가합니다. +- 재현 테스트가 실패하는 상태를 확인한 뒤 수정합니다. +- 수정 후 동일 테스트를 반복 실행해 안정 통과까지 완료합니다. diff --git a/docs/test-plan/web-e2e-test-inventory.md b/docs/test-plan/web-e2e-test-inventory.md index 7e771a51..4bf80423 100644 --- a/docs/test-plan/web-e2e-test-inventory.md +++ b/docs/test-plan/web-e2e-test-inventory.md @@ -2,6 +2,7 @@ - 범위: `adminfront/tests/*.spec.ts`, `devfront/tests/*.spec.ts` - 기준: Playwright `test(...)` 케이스 전수 +- 참고: UserFront WASM E2E 확장 계획은 `docs/test-plan/userfront-wasm-e2e-expansion-plan.md`에서 별도 관리 | 파일 | 테스트 | 역할 | |---|---|---| diff --git a/userfront/lib/features/auth/domain/login_link_route_policy.dart b/userfront/lib/features/auth/domain/login_link_route_policy.dart new file mode 100644 index 00000000..0a3b1a5f --- /dev/null +++ b/userfront/lib/features/auth/domain/login_link_route_policy.dart @@ -0,0 +1,34 @@ +import '../../../core/i18n/locale_utils.dart'; + +bool isPublicAuthPath(String path, Uri uri) { + return path == '/signin' || + path == '/signup' || + path == '/login' || + path == '/registration' || + path == '/verify' || + path == '/verification' || + path.startsWith('/verify/') || + path.startsWith('/l/') || + path == '/approve' || + path.startsWith('/ql/') || + path == '/forgot-password' || + path == '/recovery' || + path == '/reset-password' || + path == '/error' || + path == '/settings' || + path == '/consent' || + path.startsWith('/consent/') || + uri.path.contains('/consent'); +} + +String? extractLoginShortCode(Uri uri) { + final normalizedPath = stripLocalePath(uri); + final segments = normalizedPath + .split('/') + .where((segment) => segment.isNotEmpty) + .toList(); + if (segments.length < 2 || segments.first != 'l') { + return null; + } + return segments[1]; +} diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 47539b75..59cd813b 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -14,6 +14,7 @@ import '../../../core/services/oidc_redirect_guard.dart'; import '../../../core/notifiers/auth_notifier.dart'; import '../domain/login_challenge_resolver.dart'; import '../domain/cookie_session_policy.dart'; +import '../domain/login_link_route_policy.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../../core/services/web_window.dart'; @@ -111,8 +112,8 @@ class _LoginScreenState extends ConsumerState final loginIdParam = uri.queryParameters['loginId']; final codeParam = uri.queryParameters['code']; final pendingRefParam = uri.queryParameters['pendingRef']; - final hasShortCodePath = - uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l'; + final shortCodeFromPath = extractLoginShortCode(uri); + final hasShortCodePath = shortCodeFromPath != null; final hasTokenParam = uri.queryParameters.containsKey('t'); final hasVerificationToken = widget.verificationToken != null || hasTokenParam; @@ -122,8 +123,7 @@ class _LoginScreenState extends ConsumerState final notice = uri.queryParameters['notice']; if (hasShortCodePath) { - final shortCode = uri.pathSegments[1]; - _verifyShortCode(shortCode); + _verifyShortCode(shortCodeFromPath); } if (hasLoginCode) { _verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam); diff --git a/userfront/lib/features/auth/presentation/qr_camera_bootstrap_policy.dart b/userfront/lib/features/auth/presentation/qr_camera_bootstrap_policy.dart new file mode 100644 index 00000000..657bf1ca --- /dev/null +++ b/userfront/lib/features/auth/presentation/qr_camera_bootstrap_policy.dart @@ -0,0 +1,54 @@ +enum QrCameraBootstrapStatus { + ready, + detectorUnsupported, + permissionError, + cameraError, +} + +class QrCameraBootstrapResult { + const QrCameraBootstrapResult(this.status, {this.errorDetail = ''}); + + final QrCameraBootstrapStatus status; + final String errorDetail; + + bool get isReady => status == QrCameraBootstrapStatus.ready; +} + +typedef QrOpenCameraAndPlay = Future Function(); +typedef QrStopCamera = Future Function(); + +bool isQrPermissionError(Object error) { + final raw = error.toString(); + return raw.contains('NotAllowedError') || + raw.contains('PermissionDeniedError') || + raw.contains('SecurityError'); +} + +Future bootstrapQrCamera({ + required bool hasBarcodeDetector, + required QrOpenCameraAndPlay openCameraAndPlay, + required QrStopCamera stopCamera, +}) async { + try { + await openCameraAndPlay(); + if (!hasBarcodeDetector) { + await stopCamera(); + return const QrCameraBootstrapResult( + QrCameraBootstrapStatus.detectorUnsupported, + errorDetail: 'BarcodeDetector is not supported in this browser.', + ); + } + return const QrCameraBootstrapResult(QrCameraBootstrapStatus.ready); + } catch (e) { + if (isQrPermissionError(e)) { + return QrCameraBootstrapResult( + QrCameraBootstrapStatus.permissionError, + errorDetail: e.toString(), + ); + } + return QrCameraBootstrapResult( + QrCameraBootstrapStatus.cameraError, + errorDetail: e.toString(), + ); + } +} diff --git a/userfront/lib/features/auth/presentation/qr_scan_route.dart b/userfront/lib/features/auth/presentation/qr_scan_route.dart new file mode 100644 index 00000000..b69c7905 --- /dev/null +++ b/userfront/lib/features/auth/presentation/qr_scan_route.dart @@ -0,0 +1,17 @@ +import '../../../../core/i18n/locale_utils.dart'; + +String buildQrApprovePath( + String scannedValue, { + String? localeCode, + Uri? currentUri, +}) { + final value = scannedValue.trim(); + final explicitLocale = localeCode?.trim(); + final uri = currentUri ?? Uri.base; + final resolvedLocale = explicitLocale != null && explicitLocale.isNotEmpty + ? explicitLocale.toLowerCase().replaceAll('_', '-') + : normalizeLocaleCode( + extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(), + ); + return '/$resolvedLocale/approve?ref=${Uri.encodeQueryComponent(value)}'; +} diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen.dart b/userfront/lib/features/auth/presentation/qr_scan_screen.dart index a13cf54f..fcb18617 100644 --- a/userfront/lib/features/auth/presentation/qr_scan_screen.dart +++ b/userfront/lib/features/auth/presentation/qr_scan_screen.dart @@ -1,30 +1,2 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:userfront/i18n.dart'; - -class QRScanScreen extends StatefulWidget { - const QRScanScreen({super.key}); - - @override - State createState() => _QRScanScreenState(); -} - -class _QRScanScreenState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.pop(), - ), - ), - body: const Center( - child: Text( - 'QR Scanner is temporarily disabled for WASM build stability.', - ), - ), - ); - } -} +export 'qr_scan_screen_stub.dart' + if (dart.library.js_interop) 'qr_scan_screen_web.dart'; diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart b/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart new file mode 100644 index 00000000..7f6b77d2 --- /dev/null +++ b/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:userfront/i18n.dart'; + +import 'qr_scan_route.dart'; + +class QRScanScreen extends StatefulWidget { + const QRScanScreen({super.key}); + + @override + State createState() => _QRScanScreenState(); +} + +class _QRScanScreenState extends State { + final TextEditingController _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final raw = _controller.text.trim(); + if (raw.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + tr( + 'msg.userfront.qr.permission_required', + fallback: '카메라 권한이 필요합니다.', + ), + ), + ), + ); + return; + } + context.go(buildQrApprovePath(raw)); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr( + 'msg.userfront.qr.permission_error', + fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.', + ), + ), + const SizedBox(height: 12), + TextField( + key: const ValueKey('qr_scan_manual_input'), + controller: _controller, + decoration: const InputDecoration( + labelText: 'QR Payload', + hintText: 'https://.../ql/{ref} 또는 ref', + ), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 12), + FilledButton.icon( + key: const ValueKey('qr_scan_submit_button'), + onPressed: _submit, + icon: const Icon(Icons.check_circle), + label: Text( + tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen_web.dart b/userfront/lib/features/auth/presentation/qr_scan_screen_web.dart new file mode 100644 index 00000000..40d3f962 --- /dev/null +++ b/userfront/lib/features/auth/presentation/qr_scan_screen_web.dart @@ -0,0 +1,293 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:ui_web' as ui_web; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:userfront/i18n.dart'; +import 'package:web/web.dart' as web; + +import 'qr_camera_bootstrap_policy.dart'; +import 'qr_scan_route.dart'; + +@JS('BarcodeDetector') +extension type _BarcodeDetector._(JSObject _) implements JSObject { + external factory _BarcodeDetector(); + external JSPromise> detect(JSObject source); +} + +extension type _DetectedBarcode._(JSObject _) implements JSObject { + external String? get rawValue; +} + +class QRScanScreen extends StatefulWidget { + const QRScanScreen({super.key}); + + @override + State createState() => _QRScanScreenState(); +} + +class _QRScanScreenState extends State { + late final web.HTMLVideoElement _videoElement; + late final String _viewType; + final TextEditingController _manualController = TextEditingController(); + + web.MediaStream? _stream; + _BarcodeDetector? _detector; + Timer? _scanTimer; + bool _scanInFlight = false; + bool _navigated = false; + bool _initializing = true; + String? _error; + String? _status; + + @override + void initState() { + super.initState(); + _viewType = 'qr-camera-${DateTime.now().microsecondsSinceEpoch}'; + _videoElement = web.HTMLVideoElement() + ..autoplay = true + ..muted = true + ..playsInline = true + ..style.border = '0' + ..style.width = '100%' + ..style.height = '100%' + ..style.objectFit = 'cover'; + + ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) { + return _videoElement; + }); + + unawaited(_initializeScanner()); + } + + @override + void dispose() { + _scanTimer?.cancel(); + _stopCamera(); + _manualController.dispose(); + super.dispose(); + } + + Future _initializeScanner() async { + setState(() { + _initializing = true; + _error = null; + _status = tr('ui.userfront.qr.title', fallback: 'Scan QR Code'); + }); + final hasBarcodeDetector = globalContext + .hasProperty('BarcodeDetector'.toJS) + .toDart; + + final result = await bootstrapQrCamera( + hasBarcodeDetector: hasBarcodeDetector, + openCameraAndPlay: () async { + final constraints = web.MediaStreamConstraints( + video: web.MediaTrackConstraints(facingMode: 'environment'.toJS), + audio: false.toJS, + ); + _stream = await web.window.navigator.mediaDevices + .getUserMedia(constraints) + .toDart; + _videoElement.srcObject = _stream; + await _videoElement.play().toDart; + }, + stopCamera: () async { + _stopCamera(); + }, + ); + + if (!mounted) { + return; + } + + switch (result.status) { + case QrCameraBootstrapStatus.ready: + _detector = _BarcodeDetector(); + setState(() { + _initializing = false; + _status = tr( + 'msg.userfront.login.qr.scan_hint', + fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.', + ); + }); + _startScanLoop(); + break; + case QrCameraBootstrapStatus.detectorUnsupported: + setState(() { + _initializing = false; + _status = tr( + 'msg.userfront.login.qr.scan_hint', + fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.', + ); + _error = tr( + 'msg.userfront.qr.camera_error', + fallback: 'Camera error: {{error}}', + params: {'error': result.errorDetail}, + ); + }); + break; + case QrCameraBootstrapStatus.permissionError: + setState(() { + _initializing = false; + _error = tr( + 'msg.userfront.qr.permission_error', + fallback: 'Permission request failed. Check browser/OS settings.', + ); + }); + break; + case QrCameraBootstrapStatus.cameraError: + setState(() { + _initializing = false; + _error = tr( + 'msg.userfront.qr.camera_error', + fallback: 'Camera error: {{error}}', + params: {'error': result.errorDetail}, + ); + }); + break; + } + } + + void _startScanLoop() { + _scanTimer?.cancel(); + _scanTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { + if (!mounted || _scanInFlight || _navigated) { + return; + } + final detector = _detector; + if (detector == null) { + return; + } + if (_videoElement.readyState < web.HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + + _scanInFlight = true; + try { + final detected = await detector.detect(_videoElement).toDart; + final items = detected.toDart; + for (final item in items) { + final raw = item.rawValue?.trim(); + if (raw != null && raw.isNotEmpty) { + _handleScanSuccess(raw); + break; + } + } + } catch (_) { + } finally { + _scanInFlight = false; + } + }); + } + + void _stopCamera() { + final stream = _stream; + if (stream == null) { + return; + } + final tracks = stream.getTracks().toDart; + for (final track in tracks) { + track.stop(); + } + _videoElement.srcObject = null; + _stream = null; + } + + void _handleScanSuccess(String payload) { + if (_navigated || !mounted) { + return; + } + _navigated = true; + _scanTimer?.cancel(); + _stopCamera(); + context.go(buildQrApprovePath(payload)); + } + + void _submitManual() { + final payload = _manualController.text.trim(); + if (payload.isEmpty) { + return; + } + _handleScanSuccess(payload); + } + + Future _retry() async { + _scanTimer?.cancel(); + _stopCamera(); + _detector = null; + await _initializeScanner(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AspectRatio( + aspectRatio: 3 / 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(12), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: _initializing + ? const Center(child: CircularProgressIndicator()) + : HtmlElementView(viewType: _viewType), + ), + ), + ), + const SizedBox(height: 12), + if (_status != null) Text(_status!, textAlign: TextAlign.center), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ], + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _initializing ? null : _retry, + icon: const Icon(Icons.refresh), + label: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')), + ), + const SizedBox(height: 12), + TextField( + key: const ValueKey('qr_scan_manual_input'), + controller: _manualController, + decoration: const InputDecoration( + labelText: 'QR Payload', + hintText: 'https://.../ql/{ref} 또는 ref', + ), + onSubmitted: (_) => _submitManual(), + ), + const SizedBox(height: 8), + FilledButton.icon( + key: const ValueKey('qr_scan_submit_button'), + onPressed: _submitManual, + icon: const Icon(Icons.check_circle), + label: Text( + tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index ae2f26d8..89dd0a20 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -14,6 +14,7 @@ import 'features/auth/presentation/qr_scan_screen.dart'; import 'features/auth/presentation/forgot_password_screen.dart'; import 'features/auth/presentation/reset_password_screen.dart'; import 'features/auth/presentation/error_screen.dart'; +import 'features/auth/domain/login_link_route_policy.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; import 'features/admin/presentation/user_management_screen.dart'; import 'features/profile/presentation/pages/profile_page.dart'; @@ -328,25 +329,7 @@ final _router = GoRouter( (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie(); final path = stripLocalePath(uri); - // Precise public path detection - final isPublicPath = - path == '/signin' || - path == '/signup' || - path == '/login' || - path == '/registration' || - path == '/verify' || - path == '/verification' || - path.startsWith('/verify/') || - path == '/approve' || - path.startsWith('/ql/') || - path == '/forgot-password' || - path == '/recovery' || - path == '/reset-password' || - path == '/error' || - path == '/settings' || - path == '/consent' || - path.startsWith('/consent/') || - uri.path.contains('/consent'); + final isPublicPath = isPublicAuthPath(path, uri); if (isPublicPath) { return null; diff --git a/userfront/test/login_link_route_policy_test.dart b/userfront/test/login_link_route_policy_test.dart new file mode 100644 index 00000000..d81c8aff --- /dev/null +++ b/userfront/test/login_link_route_policy_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/i18n/locale_registry.dart'; +import 'package:userfront/features/auth/domain/login_link_route_policy.dart'; + +void main() { + group('login_link_route_policy', () { + setUp(() { + LocaleRegistry.setSupportedLocaleCodesForTest(['ko', 'en']); + }); + + tearDown(() { + LocaleRegistry.resetForTest(); + }); + + test('extracts short code from plain short-code route', () { + final shortCode = extractLoginShortCode(Uri.parse('/l/AB123456')); + expect(shortCode, 'AB123456'); + }); + + test('extracts short code from localized short-code route', () { + final shortCode = extractLoginShortCode(Uri.parse('/ko/l/AB123456')); + expect(shortCode, 'AB123456'); + }); + + test('treats localized short-code route as public path', () { + final isPublic = isPublicAuthPath( + '/l/AB123456', + Uri.parse('/ko/l/AB123456'), + ); + expect(isPublic, isTrue); + }); + }); +} diff --git a/userfront/test/qr_camera_bootstrap_policy_test.dart b/userfront/test/qr_camera_bootstrap_policy_test.dart new file mode 100644 index 00000000..0f265ac6 --- /dev/null +++ b/userfront/test/qr_camera_bootstrap_policy_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/auth/presentation/qr_camera_bootstrap_policy.dart'; + +void main() { + group('bootstrapQrCamera', () { + test('권한 허용 후 카메라 실행 성공 시 ready 상태를 반환한다', () async { + var stopCalled = false; + + final result = await bootstrapQrCamera( + hasBarcodeDetector: true, + openCameraAndPlay: () async {}, + stopCamera: () async { + stopCalled = true; + }, + ); + + expect(result.status, QrCameraBootstrapStatus.ready); + expect(stopCalled, isFalse); + }); + + test('권한 허용 후 play 단계 오류는 cameraError로 분류한다', () async { + var stopCalled = false; + + final result = await bootstrapQrCamera( + hasBarcodeDetector: true, + openCameraAndPlay: () async { + throw Exception('NotReadableError: Could not start video source'); + }, + stopCamera: () async { + stopCalled = true; + }, + ); + + expect(result.status, QrCameraBootstrapStatus.cameraError); + expect(result.errorDetail, contains('NotReadableError')); + expect(stopCalled, isFalse); + }); + + test('권한 거부 오류는 permissionError로 분류한다', () async { + final result = await bootstrapQrCamera( + hasBarcodeDetector: true, + openCameraAndPlay: () async { + throw Exception('NotAllowedError: Permission denied'); + }, + stopCamera: () async {}, + ); + + expect(result.status, QrCameraBootstrapStatus.permissionError); + expect(result.errorDetail, contains('NotAllowedError')); + }); + + test('detector 미지원이면 카메라를 정리하고 detectorUnsupported를 반환한다', () async { + var stopCalled = false; + + final result = await bootstrapQrCamera( + hasBarcodeDetector: false, + openCameraAndPlay: () async {}, + stopCamera: () async { + stopCalled = true; + }, + ); + + expect(result.status, QrCameraBootstrapStatus.detectorUnsupported); + expect(stopCalled, isTrue); + }); + }); +} diff --git a/userfront/test/qr_scan_route_test.dart b/userfront/test/qr_scan_route_test.dart new file mode 100644 index 00000000..db0a4e4c --- /dev/null +++ b/userfront/test/qr_scan_route_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/auth/presentation/qr_scan_route.dart'; + +void main() { + group('buildQrApprovePath', () { + test('스캔 값을 trim/encode 해서 approve 경로를 만든다', () { + final result = buildQrApprovePath( + ' https://sss.hmac.kr/ql/abc-123?x=1&y=2 ', + localeCode: 'ko', + ); + + expect( + result, + '/ko/approve?ref=https%3A%2F%2Fsss.hmac.kr%2Fql%2Fabc-123%3Fx%3D1%26y%3D2', + ); + }); + + test('현재 URI에서 locale을 추출한다', () { + final result = buildQrApprovePath( + 'abc123', + currentUri: Uri.parse('https://sss.hmac.kr/en/dashboard'), + ); + + expect(result, '/en/approve?ref=abc123'); + }); + }); +} diff --git a/userfront/test/qr_scan_screen_test.dart b/userfront/test/qr_scan_screen_test.dart new file mode 100644 index 00000000..fa3e31f2 --- /dev/null +++ b/userfront/test/qr_scan_screen_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/auth/presentation/qr_scan_screen.dart'; + +void main() { + testWidgets('QR 스캔 화면은 비활성 문구 대신 입력/이동 UI를 노출한다', (tester) async { + await tester.pumpWidget(const MaterialApp(home: QRScanScreen())); + + expect( + find.text('QR Scanner is temporarily disabled for WASM build stability.'), + findsNothing, + ); + expect(find.byKey(const ValueKey('qr_scan_manual_input')), findsOneWidget); + expect(find.byKey(const ValueKey('qr_scan_submit_button')), findsOneWidget); + }); +}