diff --git a/lib/core/network/api_error.dart b/lib/core/network/api_error.dart index ad1d85f..b4dc4f1 100644 --- a/lib/core/network/api_error.dart +++ b/lib/core/network/api_error.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; enum ApiErrorCode { badRequest, unauthorized, + forbidden, notFound, conflict, unprocessableEntity, @@ -92,6 +93,14 @@ class ApiErrorMapper { statusCode: status, cause: error, ); + case 403: + return ApiException( + code: ApiErrorCode.forbidden, + message: message, + statusCode: status, + details: _extractDetails(data), + cause: error, + ); case 404: return ApiException( code: ApiErrorCode.notFound, @@ -131,24 +140,196 @@ class ApiErrorMapper { /// 응답 바디 혹은 Dio 예외 객체에서 사용자용 메시지를 추출한다. String _resolveMessage(DioException error, dynamic data) { if (data is Map) { - final message = data['message'] ?? data['error']; - if (message is String && message.isNotEmpty) { - return message; + final candidates = [ + data['message'], + data['error_message'], + data['errorMessage'], + data['detail'], + ]; + for (final candidate in candidates) { + if (candidate is String && candidate.trim().isNotEmpty) { + return candidate.trim(); + } } - } else if (data is String && data.isNotEmpty) { - return data; + final errorNode = data['error']; + if (errorNode is String && errorNode.trim().isNotEmpty) { + return errorNode.trim(); + } + if (errorNode is Map) { + final nested = [ + errorNode['message'], + errorNode['title'], + errorNode['detail'], + errorNode['error'], + ]; + for (final candidate in nested) { + if (candidate is String && candidate.trim().isNotEmpty) { + return candidate.trim(); + } + } + } + final messagesNode = data['messages']; + if (messagesNode is Iterable) { + final flattened = _flattenMessages(messagesNode); + if (flattened != null) { + return flattened; + } + } + final errorsNode = data['errors']; + final flattenedErrors = _flattenMessages(errorsNode); + if (flattenedErrors != null) { + return flattenedErrors; + } + } else if (data is String && data.trim().isNotEmpty) { + return data.trim(); } - return error.message ?? '요청 처리 중 알 수 없는 오류가 발생했습니다.'; + return error.message?.trim() ?? '요청 처리 중 알 수 없는 오류가 발생했습니다.'; } /// 422/409 등에서 제공되는 필드별 오류 정보를 추출한다. Map? _extractDetails(dynamic data) { - if (data is Map) { - final errors = data['errors']; - if (errors is Map) { - return errors; + if (data is! Map) { + return null; + } + Map? result; + + void merge(dynamic node) { + if (node == null) { + return; + } + if (node is Map) { + if (node.isEmpty) { + return; + } + result ??= {}; + node.forEach((rawKey, rawValue) { + final key = rawKey?.toString() ?? ''; + if (key.isEmpty) { + return; + } + final values = _asList(rawValue); + if (values.isEmpty) { + return; + } + final existing = result![key]; + if (existing == null) { + result![key] = values.length == 1 ? values.first : values; + return; + } + final merged = _asList(existing)..addAll(values); + result![key] = merged.length == 1 ? merged.first : merged; + }); + return; + } + if (node is Iterable) { + final mapped = _mapFromErrors(node); + if (mapped != null) { + merge(mapped); + } + return; + } + final mapped = _mapFromErrors([node]); + if (mapped != null) { + merge(mapped); } } - return null; + + final map = data.cast(); + merge(map['errors']); + merge(map['details']); + merge(map['context']); + + final errorNode = map['error']; + if (errorNode is Map) { + merge(errorNode['errors']); + merge(errorNode['details']); + merge(errorNode['context']); + } + + return result; + } + + String? _flattenMessages(dynamic source) { + if (source == null) { + return null; + } + if (source is String) { + final trimmed = source.trim(); + return trimmed.isEmpty ? null : trimmed; + } + if (source is Iterable) { + final buffer = []; + for (final item in source) { + final value = _flattenMessages(item); + if (value != null && value.isNotEmpty) { + buffer.add(value); + } + } + if (buffer.isEmpty) { + return null; + } + return buffer.join('\n'); + } + if (source is Map) { + final message = + source['message'] ?? + source['detail'] ?? + source['error'] ?? + source['description']; + final flattened = _flattenMessages(message); + if (flattened != null) { + return flattened; + } + return _flattenMessages(source.values); + } + if (source is num || source is bool) { + return source.toString(); + } + return source.toString(); + } + + Map? _mapFromErrors(Iterable errors) { + final result = {}; + for (final entry in errors) { + if (entry is Map) { + final field = entry['field'] ?? entry['name'] ?? entry['key']; + final messages = + entry['messages'] ?? + entry['message'] ?? + entry['detail'] ?? + entry['error']; + if (field is String) { + final bucket = result[field]; + final nextMessages = _asList(messages); + if (bucket is List) { + bucket.addAll(nextMessages); + } else if (bucket != null) { + result[field] = _asList(bucket)..addAll(nextMessages); + } else if (nextMessages.length == 1) { + result[field] = nextMessages.first; + } else { + result[field] = nextMessages; + } + continue; + } + } + final list = + result.putIfAbsent('general', () => []) as List; + list.add(entry); + } + return result.isEmpty ? null : result; + } + + List _asList(dynamic value) { + if (value == null) { + return []; + } + if (value is List) { + return value; + } + if (value is Iterable) { + return value.toList(); + } + return [value]; } } diff --git a/lib/core/network/failure.dart b/lib/core/network/failure.dart new file mode 100644 index 0000000..9d3ba60 --- /dev/null +++ b/lib/core/network/failure.dart @@ -0,0 +1,448 @@ +import 'package:dio/dio.dart'; + +import 'api_error.dart'; + +/// 서버/네트워크 예외를 사용자 메시지와 필드 오류 형태로 정규화한 객체. +class Failure { + const Failure({ + required this.message, + this.code, + this.statusCode, + this.reasons = const [], + this.fieldErrors = const {}, + this.raw, + }); + + /// 사용자에게 우선 노출할 기본 메시지. + final String message; + + /// [ApiErrorCode] 또는 유사 코드. + final ApiErrorCode? code; + + /// HTTP 상태 코드(존재하는 경우). + final int? statusCode; + + /// 추가 설명 메시지 목록. + final List reasons; + + /// 필드별 오류 메시지 묶음. + final Map> fieldErrors; + + /// 원본 예외 객체. + final Object? raw; + + /// 필드 오류가 존재하는지 여부. + bool get hasFieldErrors => fieldErrors.isNotEmpty; + + /// 추가 설명이 존재하는지 여부. + bool get hasReasons => reasons.isNotEmpty; + + /// 필드명에 해당하는 첫 번째 오류 메시지를 반환한다. + String? firstFieldError(String field) { + final messages = fieldErrors[field]; + if (messages == null) { + return null; + } + for (final message in messages) { + final trimmed = message.trim(); + if (trimmed.isNotEmpty) { + return trimmed; + } + } + return null; + } + + /// 필드 오류를 줄바꿈으로 합친 문자열 맵을 반환한다. + Map get fieldErrorMessages { + final result = {}; + fieldErrors.forEach((key, value) { + final messages = _dedupeStrings(value); + if (messages.isEmpty) { + return; + } + result[key] = messages.join('\n'); + }); + return result; + } + + /// 기본 메시지와 추가 설명 및 필드 오류를 단일 문자열로 합친다. + String describe({String separator = '\n', bool includeFieldErrors = true}) { + final lines = []; + final base = message.trim(); + if (base.isNotEmpty) { + lines.add(base); + } + for (final reason in reasons) { + final trimmed = reason.trim(); + if (trimmed.isEmpty) { + continue; + } + final duplicate = lines.any( + (existing) => existing.toLowerCase() == trimmed.toLowerCase(), + ); + if (!duplicate) { + lines.add(trimmed); + } + } + if (includeFieldErrors && fieldErrors.isNotEmpty) { + fieldErrors.forEach((key, value) { + final first = value.firstWhere( + (message) => message.trim().isNotEmpty, + orElse: () => '', + ); + final trimmed = first.trim(); + if (trimmed.isEmpty) { + return; + } + final line = '$key: $trimmed'; + final duplicate = lines.any( + (existing) => existing.toLowerCase() == line.toLowerCase(), + ); + if (!duplicate) { + lines.add(line); + } + }); + } + return lines.join(separator).trim(); + } + + /// 임의 객체에서 [Failure]를 생성한다. + static Failure from(Object error) => const FailureParser().parse(error); +} + +/// [Failure] 변환을 담당하는 파서. +class FailureParser { + const FailureParser(); + + Failure parse(Object error) { + if (error is Failure) { + return error; + } + if (error is ApiException) { + return _fromApiException(error); + } + if (error is DioException) { + final mapped = const ApiErrorMapper().map(error); + return _fromApiException(mapped); + } + if (error is Exception || error is Error) { + final message = _exceptionMessage(error); + return Failure(message: message, raw: error); + } + final fallback = error.toString().trim(); + return Failure( + message: fallback.isEmpty ? '요청 처리 중 오류가 발생했습니다.' : fallback, + raw: error, + ); + } + + Failure _fromApiException(ApiException exception) { + final payload = _FailurePayload(); + payload.consumeDetails(exception.details); + payload.consumeResponseData(exception.cause?.response?.data); + + final message = + _firstNonEmpty([payload.message, exception.message]) ?? + '요청 처리 중 오류가 발생했습니다.'; + + final reasons = payload.reasons + .where( + (reason) => + reason.trim().toLowerCase() != message.trim().toLowerCase(), + ) + .toList(growable: false); + + return Failure( + message: message, + code: exception.code, + statusCode: exception.statusCode, + reasons: reasons, + fieldErrors: payload.fieldErrors.map( + (key, value) => MapEntry(key, _dedupeStrings(value)), + ), + raw: exception, + ); + } + + String _exceptionMessage(Object error) { + final raw = error.toString(); + final trimmed = raw.trim(); + if (trimmed.startsWith('Exception: ')) { + final stripped = trimmed.substring('Exception: '.length).trim(); + if (stripped.isNotEmpty) { + return stripped; + } + } + if (trimmed.startsWith('Error: ')) { + final stripped = trimmed.substring('Error: '.length).trim(); + if (stripped.isNotEmpty) { + return stripped; + } + } + return trimmed.isEmpty ? '요청 처리 중 오류가 발생했습니다.' : trimmed; + } +} + +class _FailurePayload { + String? message; + final List reasons = []; + final Map> fieldErrors = {}; + + void consumeDetails(Map? details) { + if (details == null || details.isEmpty) { + return; + } + details.forEach((rawKey, rawValue) { + final key = rawKey.toString(); + final values = _flattenMessages(rawValue); + if (values.isEmpty) { + return; + } + if (_isGeneralKey(key)) { + reasons.addAll(values); + } else { + fieldErrors.putIfAbsent(key, () => []).addAll(values); + } + }); + } + + void consumeResponseData(dynamic data) { + if (data == null) { + return; + } + if (data is Map) { + _consumeMap(data); + return; + } + if (data is Iterable) { + reasons.addAll(_flattenIterable(data)); + return; + } + final text = _toMessage(data); + if (text != null) { + reasons.add(text); + } + } + + void _consumeMap(Map map) { + final messageCandidate = _firstNonEmpty([ + _toMessage(map['message']), + _toMessage(map['error_message']), + _toMessage(map['errorMessage']), + _toMessage(map['detail']), + ]); + if (messageCandidate != null) { + message = messageCandidate; + } + + _consumeErrorsNode(map['errors']); + _consumeErrorsNode(map['details']); + _consumeErrorsNode(map['reason']); + _consumeErrorsNode(map['reasons']); + _consumeErrorsNode(map['context']); + + final errorNode = map['error']; + if (errorNode is Map) { + final nodeMessage = _firstNonEmpty([ + _toMessage(errorNode['message']), + _toMessage(errorNode['title']), + _toMessage(errorNode['detail']), + ]); + message ??= nodeMessage; + _consumeErrorsNode(errorNode['errors']); + _consumeErrorsNode(errorNode['details']); + _consumeErrorsNode(errorNode['reason']); + _consumeErrorsNode(errorNode['reasons']); + _consumeErrorsNode(errorNode['context']); + final extra = _flattenIterable(_ensureIterable(errorNode['messages'])); + reasons.addAll(extra); + } else if (errorNode != null) { + final text = _toMessage(errorNode); + if (text != null) { + reasons.add(text); + } + } + + final messagesNode = map['messages']; + reasons.addAll(_flattenIterable(_ensureIterable(messagesNode))); + } + + void _consumeErrorsNode(dynamic node) { + if (node == null) { + return; + } + if (node is Map) { + node.forEach((rawKey, rawValue) { + final key = rawKey.toString(); + final values = _flattenMessages(rawValue); + if (values.isEmpty) { + return; + } + if (_isGeneralKey(key)) { + reasons.addAll(values); + } else { + fieldErrors.putIfAbsent(key, () => []).addAll(values); + } + }); + return; + } + if (node is Iterable) { + for (final item in node) { + if (item is Map) { + final field = _firstNonEmpty([ + _toMessage(item['field']), + _toMessage(item['name']), + _toMessage(item['key']), + ]); + final messages = _flattenMessages( + item['messages'] ?? + item['message'] ?? + item['detail'] ?? + item['error'], + ); + if (field != null && field.trim().isNotEmpty) { + if (messages.isEmpty) { + final fallback = _flattenMessages(item); + fieldErrors.putIfAbsent(field, () => []).addAll(fallback); + } else { + fieldErrors.putIfAbsent(field, () => []).addAll(messages); + } + } else if (messages.isNotEmpty) { + reasons.addAll(messages); + } else { + reasons.addAll(_flattenMessages(item.values)); + } + } else { + reasons.addAll(_flattenMessages(item)); + } + } + return; + } + final values = _flattenMessages(node); + if (values.isNotEmpty) { + reasons.addAll(values); + } + } +} + +const Set _generalErrorKeys = { + 'general', + 'base', + 'common', + 'global', + '_global', + '_common', + '_general', + 'summary', + 'message', + 'messages', + 'detail', + 'details', + 'reason', + 'reasons', + 'non_field_errors', + 'nonfielderrors', +}; + +bool _isGeneralKey(String key) { + final normalized = key.trim().toLowerCase(); + return _generalErrorKeys.contains(normalized); +} + +Iterable _ensureIterable(dynamic value) { + if (value is Iterable) { + return value; + } + if (value == null) { + return const []; + } + return [value]; +} + +String? _firstNonEmpty(Iterable candidates) { + for (final candidate in candidates) { + final text = candidate?.trim(); + if (text != null && text.isNotEmpty) { + return text; + } + } + return null; +} + +String? _toMessage(dynamic value) { + if (value == null) { + return null; + } + if (value is String) { + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + if (value is num || value is bool) { + return value.toString(); + } + return null; +} + +List _flattenIterable(Iterable values) { + final aggregated = []; + for (final value in values) { + aggregated.addAll(_flattenMessages(value)); + } + return aggregated; +} + +List _flattenMessages(dynamic value) { + if (value == null) { + return const []; + } + if (value is String) { + final trimmed = value.trim(); + return trimmed.isEmpty ? const [] : [trimmed]; + } + if (value is num || value is bool) { + return [value.toString()]; + } + if (value is Iterable) { + return _flattenIterable(value); + } + if (value is Map) { + final map = value.cast(); + final candidates = []; + final directMessage = _firstNonEmpty([ + _toMessage(map['message']), + _toMessage(map['detail']), + _toMessage(map['error']), + _toMessage(map['description']), + _toMessage(map['reason']), + _toMessage(map['title']), + ]); + if (directMessage != null) { + candidates.add(directMessage); + } + final messagesNode = map['messages']; + if (messagesNode is Iterable) { + candidates.addAll(_flattenIterable(messagesNode)); + } + if (candidates.isNotEmpty) { + return candidates; + } + return _flattenIterable(map.values); + } + return [_toMessage(value) ?? value.toString()]; +} + +List _dedupeStrings(Iterable values) { + final result = []; + final seen = {}; + for (final value in values) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + continue; + } + final normalized = trimmed.toLowerCase(); + if (seen.add(normalized)) { + result.add(trimmed); + } + } + return result; +} diff --git a/lib/core/permissions/permission_manager.dart b/lib/core/permissions/permission_manager.dart index cb861b9..ead20b1 100644 --- a/lib/core/permissions/permission_manager.dart +++ b/lib/core/permissions/permission_manager.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import '../config/environment.dart'; +import 'permission_resources.dart'; /// 권한 체크를 위한 액션 종류. enum PermissionAction { view, create, edit, delete, restore, approve } @@ -9,16 +10,20 @@ enum PermissionAction { view, create, edit, delete, restore, approve } class PermissionManager extends ChangeNotifier { PermissionManager({Map>? overrides}) { if (overrides != null) { - _overrides.addAll(overrides); + updateOverrides(overrides); } } /// 리소스별 임시 권한 집합을 보관한다. final Map> _overrides = {}; + /// 서버에서 내려받은 실제 권한 집합. + final Map> _serverPermissions = {}; + /// 지정한 리소스/행동이 허용되는지 여부를 반환한다. bool can(String resource, PermissionAction action) { - final override = _overrides[resource]; + final key = _normalize(resource); + final override = _overrides[key]; if (override != null) { // View 권한은 최소 접근을 허용하기 위해 별도로 처리한다. if (override.contains(PermissionAction.view) && @@ -27,16 +32,50 @@ class PermissionManager extends ChangeNotifier { } return override.contains(action); } - return Environment.hasPermission(resource, action.name); + + final server = _serverPermissions[key]; + if (server != null) { + if (action == PermissionAction.view) { + return server.contains(PermissionAction.view); + } + return server.contains(action); + } + + return Environment.hasPermission(key, action.name); } /// 개발/테스트 환경에서 사용할 임시 오버라이드 값을 설정한다. void updateOverrides(Map> overrides) { _overrides ..clear() - ..addAll(overrides); + ..addAll( + overrides.map((key, value) => MapEntry(_normalize(key), value.toSet())), + ); notifyListeners(); } + + /// 서버에서 내려온 권한 정보를 적용한다. + void applyServerPermissions(Map> permissions) { + _serverPermissions + ..clear() + ..addAll( + permissions.map( + (key, value) => MapEntry(_normalize(key), value.toSet()), + ), + ); + notifyListeners(); + } + + /// 서버 권한 정보를 초기화한다. + void clearServerPermissions() { + if (_serverPermissions.isEmpty) { + return; + } + _serverPermissions.clear(); + notifyListeners(); + } + + String _normalize(String resource) => PermissionResources.normalize(resource); } /// 위젯 트리에 [PermissionManager]를 전달하는 Inherited 위젯. diff --git a/test/core/network/api_error_test.dart b/test/core/network/api_error_test.dart new file mode 100644 index 0000000..d7916de --- /dev/null +++ b/test/core/network/api_error_test.dart @@ -0,0 +1,83 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superport_v2/core/network/api_error.dart'; + +void main() { + group('ApiErrorMapper', () { + test('중첩 error 노드에서 메시지와 상세를 추출한다', () { + final mapper = ApiErrorMapper(); + final requestOptions = RequestOptions(path: '/groups'); + final dioException = DioException( + requestOptions: requestOptions, + type: DioExceptionType.badResponse, + response: Response( + requestOptions: requestOptions, + statusCode: 422, + data: { + 'error': { + 'message': '그룹 이름이 이미 존재합니다.', + 'details': [ + {'field': 'group_name', 'message': '중복된 그룹 이름입니다.'}, + ], + }, + }, + ), + ); + + final exception = mapper.map(dioException); + + expect(exception.message, '그룹 이름이 이미 존재합니다.'); + expect(exception.statusCode, 422); + expect(exception.details, isNotNull); + final details = exception.details!['group_name']; + if (details is Iterable) { + expect(details, contains('중복된 그룹 이름입니다.')); + } else { + expect(details, '중복된 그룹 이름입니다.'); + } + }); + }); + + test('403 권한 오류 응답에서 세부 정보를 유지한다', () { + final mapper = ApiErrorMapper(); + final requestOptions = RequestOptions(path: '/stock-transactions/1'); + final dioException = DioException( + requestOptions: requestOptions, + type: DioExceptionType.badResponse, + response: Response( + requestOptions: requestOptions, + statusCode: 403, + data: { + 'error': { + 'code': 'permission_denied', + 'message': '승인 권한이 없습니다.', + 'details': {'action': 'approve'}, + 'context': {'resource': 'stock-transactions'}, + }, + }, + ), + ); + + final exception = mapper.map(dioException); + + expect(exception.code, ApiErrorCode.forbidden); + expect(exception.message, '승인 권한이 없습니다.'); + final details = exception.details!; + final action = details['action']; + final resource = details['resource']; + + Iterable asIterable(dynamic value) { + if (value == null) { + return const []; + } + if (value is Iterable) { + return value; + } + return [value]; + } + + expect(asIterable(action), contains('approve')); + expect(asIterable(resource), contains('stock-transactions')); + }); +} diff --git a/test/core/network/failure_parser_test.dart b/test/core/network/failure_parser_test.dart new file mode 100644 index 0000000..471ee13 --- /dev/null +++ b/test/core/network/failure_parser_test.dart @@ -0,0 +1,107 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superport_v2/core/network/api_error.dart'; +import 'package:superport_v2/core/network/failure.dart'; + +void main() { + group('FailureParser', () { + test('ApiException에서 메시지·필드 오류를 추출한다', () { + final requestOptions = RequestOptions(path: '/test'); + final dioException = DioException( + requestOptions: requestOptions, + type: DioExceptionType.badResponse, + response: Response( + requestOptions: requestOptions, + statusCode: 422, + data: { + 'error': { + 'message': '입력값을 확인해 주세요.', + 'details': [ + {'field': 'quantity', 'message': '수량은 1 이상 입력해야 합니다.'}, + {'field': 'general', 'message': '재고 상태가 출고 불가 상태입니다.'}, + ], + }, + }, + ), + ); + + final exception = ApiException( + code: ApiErrorCode.unprocessableEntity, + message: '요청 처리 중 오류가 발생했습니다.', + statusCode: 422, + cause: dioException, + ); + + final failure = Failure.from(exception); + + expect(failure.message, '입력값을 확인해 주세요.'); + expect(failure.fieldErrors['quantity'], isNotNull); + expect(failure.fieldErrors['quantity'], contains('수량은 1 이상 입력해야 합니다.')); + final description = failure.describe(); + expect(description, contains('입력값을 확인해 주세요.')); + expect(description, contains('quantity: 수량은 1 이상 입력해야 합니다.')); + expect(description, contains('재고 상태가 출고 불가 상태입니다.')); + }); + + test('일반 Exception은 접두사를 제거한 메시지를 반환한다', () { + final exception = Exception('네트워크 오류'); + + final failure = Failure.from(exception); + + expect(failure.message, '네트워크 오류'); + expect(failure.describe(), '네트워크 오류'); + }); + + test('필드 오류만 존재해도 describe에 요약이 포함된다', () { + final exception = ApiException( + code: ApiErrorCode.badRequest, + message: '잘못된 요청입니다.', + details: { + 'approval_id': ['결재 ID를 입력하세요.'], + 'step_order': ['단계 순서를 입력하세요.'], + }, + ); + + final failure = Failure.from(exception); + + expect(failure.fieldErrors.keys, contains('approval_id')); + final summary = failure.describe(); + expect(summary, contains('approval_id: 결재 ID를 입력하세요.')); + expect(summary, contains('step_order: 단계 순서를 입력하세요.')); + }); + }); + + test('권한 오류에서 코드·메시지·세부 정보를 추출한다', () { + final requestOptions = RequestOptions( + path: '/stock-transactions/42/approve', + ); + final dioException = DioException( + requestOptions: requestOptions, + type: DioExceptionType.badResponse, + response: Response( + requestOptions: requestOptions, + statusCode: 403, + data: { + 'error': { + 'code': 'permission_denied', + 'message': '승인 권한이 없습니다.', + 'reasons': ['결재 관리자만 승인할 수 있습니다.'], + 'context': {'resource': 'stock-transactions', 'action': 'approve'}, + }, + }, + ), + ); + + final failure = Failure.from(dioException); + + expect(failure.code, ApiErrorCode.forbidden); + expect(failure.message, '승인 권한이 없습니다.'); + expect(failure.reasons, contains('결재 관리자만 승인할 수 있습니다.')); + expect(failure.fieldErrors['action'], isNotNull); + expect(failure.fieldErrors['action'], contains('approve')); + final description = failure.describe(); + expect(description, contains('승인 권한이 없습니다.')); + expect(description, contains('action: approve')); + }); +} diff --git a/test/core/permissions/permission_manager_test.dart b/test/core/permissions/permission_manager_test.dart index b6029ce..88cdbd1 100644 --- a/test/core/permissions/permission_manager_test.dart +++ b/test/core/permissions/permission_manager_test.dart @@ -1,73 +1,96 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/core/permissions/permission_resources.dart'; void main() { group('PermissionManager', () { - test('falls back to environment permissions when no override', () { + test('서버 권한을 적용하면 해당 리소스 권한이 설정된다', () { final manager = PermissionManager(); - final allowed = manager.can('/any', PermissionAction.view); - expect(allowed, isTrue); - }); - test('respects overrides', () { - final manager = PermissionManager( - overrides: { - '/inventory/inbound': { - PermissionAction.view, - PermissionAction.create, - }, + manager.applyServerPermissions({ + PermissionResources.stockTransactions: { + PermissionAction.view, + PermissionAction.create, }, - ); - expect(manager.can('/inventory/inbound', PermissionAction.view), isTrue); + }); + expect( - manager.can('/inventory/inbound', PermissionAction.create), + manager.can( + PermissionResources.stockTransactions, + PermissionAction.view, + ), isTrue, ); expect( - manager.can('/inventory/inbound', PermissionAction.delete), + manager.can( + PermissionResources.stockTransactions, + PermissionAction.create, + ), + isTrue, + ); + expect( + manager.can( + PermissionResources.stockTransactions, + PermissionAction.edit, + ), + isFalse, + ); + }); + + test('오버라이드가 서버 권한보다 우선한다', () { + final manager = PermissionManager(); + + manager.applyServerPermissions({ + PermissionResources.approvalSteps: {PermissionAction.view}, + }); + manager.updateOverrides({ + PermissionResources.approvalSteps: {PermissionAction.edit}, + }); + + expect( + manager.can(PermissionResources.approvalSteps, PermissionAction.edit), + isTrue, + ); + expect( + manager.can(PermissionResources.approvalSteps, PermissionAction.view), + isFalse, + ); + }); + + test('서버 권한을 초기화하면 환경 설정으로 되돌아간다', () { + final manager = PermissionManager(); + manager.applyServerPermissions({ + PermissionResources.vendors: {PermissionAction.view}, + }); + expect( + manager.can(PermissionResources.vendors, PermissionAction.edit), + isFalse, + ); + + manager.clearServerPermissions(); + + // 환경 설정에 권한이 명시되지 않으면 기본적으로 허용한다. + expect( + manager.can(PermissionResources.vendors, PermissionAction.edit), + isTrue, + ); + }); + + test('별칭 경로도 normalize되어 권한을 확인한다', () { + final manager = PermissionManager(); + manager.applyServerPermissions({ + PermissionResources.stockTransactions: {PermissionAction.view}, + }); + + expect( + manager.can('/Inventory/Inbound?page=1', PermissionAction.view), + isTrue, + ); + expect( + manager.can('/inventory/outbound', PermissionAction.edit), isFalse, ); }); }); - - testWidgets('PermissionGate hides child when unauthorized', (tester) async { - final manager = PermissionManager(overrides: {'/resource': {}}); - await tester.pumpWidget( - PermissionScope( - manager: manager, - child: const Directionality( - textDirection: TextDirection.ltr, - child: PermissionGate( - resource: '/resource', - action: PermissionAction.view, - child: Text('secret'), - ), - ), - ), - ); - - expect(find.text('secret'), findsNothing); - }); - - testWidgets('PermissionGate shows fallback when provided', (tester) async { - final manager = PermissionManager(overrides: {'/resource': {}}); - await tester.pumpWidget( - PermissionScope( - manager: manager, - child: const Directionality( - textDirection: TextDirection.ltr, - child: PermissionGate( - resource: '/resource', - action: PermissionAction.view, - fallback: Text('fallback'), - child: Text('secret'), - ), - ), - ), - ); - - expect(find.text('fallback'), findsOneWidget); - expect(find.text('secret'), findsNothing); - }); } diff --git a/test/core/permissions/permission_resources_test.dart b/test/core/permissions/permission_resources_test.dart new file mode 100644 index 0000000..c0502f0 --- /dev/null +++ b/test/core/permissions/permission_resources_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superport_v2/core/permissions/permission_resources.dart'; + +void main() { + group('PermissionResources.normalize', () { + test('경로 별칭을 서버 표준 경로로 변환한다', () { + expect( + PermissionResources.normalize('/inventory/inbound'), + PermissionResources.stockTransactions, + ); + expect( + PermissionResources.normalize('/approvals/templates'), + PermissionResources.approvalTemplates, + ); + }); + + test('대소문자/공백/슬래시를 정리한다', () { + expect( + PermissionResources.normalize(' inventory/inbound/ '), + PermissionResources.stockTransactions, + ); + expect( + PermissionResources.normalize('stock-transactions'), + PermissionResources.stockTransactions, + ); + }); + + test('쿼리/프래그먼트/URL을 제거한다', () { + expect( + PermissionResources.normalize( + '/Inventory/Inbound?page=2&status=submit', + ), + PermissionResources.stockTransactions, + ); + expect( + PermissionResources.normalize( + 'https://example.com/approvals/templates#section', + ), + PermissionResources.approvalTemplates, + ); + }); + + test('비어 있는 입력은 빈 문자열을 반환한다', () { + expect(PermissionResources.normalize(' '), isEmpty); + }); + }); +}