chore: API 오류 매핑과 Failure 파서 고도화
This commit is contained in:
@@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
|
|||||||
enum ApiErrorCode {
|
enum ApiErrorCode {
|
||||||
badRequest,
|
badRequest,
|
||||||
unauthorized,
|
unauthorized,
|
||||||
|
forbidden,
|
||||||
notFound,
|
notFound,
|
||||||
conflict,
|
conflict,
|
||||||
unprocessableEntity,
|
unprocessableEntity,
|
||||||
@@ -92,6 +93,14 @@ class ApiErrorMapper {
|
|||||||
statusCode: status,
|
statusCode: status,
|
||||||
cause: error,
|
cause: error,
|
||||||
);
|
);
|
||||||
|
case 403:
|
||||||
|
return ApiException(
|
||||||
|
code: ApiErrorCode.forbidden,
|
||||||
|
message: message,
|
||||||
|
statusCode: status,
|
||||||
|
details: _extractDetails(data),
|
||||||
|
cause: error,
|
||||||
|
);
|
||||||
case 404:
|
case 404:
|
||||||
return ApiException(
|
return ApiException(
|
||||||
code: ApiErrorCode.notFound,
|
code: ApiErrorCode.notFound,
|
||||||
@@ -131,24 +140,196 @@ class ApiErrorMapper {
|
|||||||
/// 응답 바디 혹은 Dio 예외 객체에서 사용자용 메시지를 추출한다.
|
/// 응답 바디 혹은 Dio 예외 객체에서 사용자용 메시지를 추출한다.
|
||||||
String _resolveMessage(DioException error, dynamic data) {
|
String _resolveMessage(DioException error, dynamic data) {
|
||||||
if (data is Map<String, dynamic>) {
|
if (data is Map<String, dynamic>) {
|
||||||
final message = data['message'] ?? data['error'];
|
final candidates = [
|
||||||
if (message is String && message.isNotEmpty) {
|
data['message'],
|
||||||
return 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) {
|
final errorNode = data['error'];
|
||||||
return data;
|
if (errorNode is String && errorNode.trim().isNotEmpty) {
|
||||||
|
return errorNode.trim();
|
||||||
|
}
|
||||||
|
if (errorNode is Map<String, dynamic>) {
|
||||||
|
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 등에서 제공되는 필드별 오류 정보를 추출한다.
|
/// 422/409 등에서 제공되는 필드별 오류 정보를 추출한다.
|
||||||
Map<String, dynamic>? _extractDetails(dynamic data) {
|
Map<String, dynamic>? _extractDetails(dynamic data) {
|
||||||
if (data is Map<String, dynamic>) {
|
if (data is! Map) {
|
||||||
final errors = data['errors'];
|
return null;
|
||||||
if (errors is Map<String, dynamic>) {
|
}
|
||||||
return errors;
|
Map<String, dynamic>? result;
|
||||||
|
|
||||||
|
void merge(dynamic node) {
|
||||||
|
if (node == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node is Map) {
|
||||||
|
if (node.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result ??= <String, dynamic>{};
|
||||||
|
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<dynamic, dynamic>();
|
||||||
|
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 = <String>[];
|
||||||
|
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<String, dynamic>? _mapFromErrors(Iterable<dynamic> errors) {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
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', () => <dynamic>[]) as List<dynamic>;
|
||||||
|
list.add(entry);
|
||||||
|
}
|
||||||
|
return result.isEmpty ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<dynamic> _asList(dynamic value) {
|
||||||
|
if (value == null) {
|
||||||
|
return <dynamic>[];
|
||||||
|
}
|
||||||
|
if (value is List) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is Iterable) {
|
||||||
|
return value.toList();
|
||||||
|
}
|
||||||
|
return <dynamic>[value];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
448
lib/core/network/failure.dart
Normal file
448
lib/core/network/failure.dart
Normal file
@@ -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<String> reasons;
|
||||||
|
|
||||||
|
/// 필드별 오류 메시지 묶음.
|
||||||
|
final Map<String, List<String>> 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<String, String> get fieldErrorMessages {
|
||||||
|
final result = <String, String>{};
|
||||||
|
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 = <String>[];
|
||||||
|
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<String> reasons = [];
|
||||||
|
final Map<String, List<String>> fieldErrors = {};
|
||||||
|
|
||||||
|
void consumeDetails(Map<String, dynamic>? 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, () => <String>[]).addAll(values);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void consumeResponseData(dynamic data) {
|
||||||
|
if (data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
_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<String, dynamic> 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<String, dynamic>) {
|
||||||
|
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<String, dynamic>) {
|
||||||
|
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, () => <String>[]).addAll(values);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node is Iterable) {
|
||||||
|
for (final item in node) {
|
||||||
|
if (item is Map<String, dynamic>) {
|
||||||
|
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, () => <String>[]).addAll(fallback);
|
||||||
|
} else {
|
||||||
|
fieldErrors.putIfAbsent(field, () => <String>[]).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<String> _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<dynamic> _ensureIterable(dynamic value) {
|
||||||
|
if (value is Iterable) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value == null) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _firstNonEmpty(Iterable<String?> 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<String> _flattenIterable(Iterable<dynamic> values) {
|
||||||
|
final aggregated = <String>[];
|
||||||
|
for (final value in values) {
|
||||||
|
aggregated.addAll(_flattenMessages(value));
|
||||||
|
}
|
||||||
|
return aggregated;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _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<dynamic, dynamic>();
|
||||||
|
final candidates = <String>[];
|
||||||
|
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<String> _dedupeStrings(Iterable<String> values) {
|
||||||
|
final result = <String>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import '../config/environment.dart';
|
import '../config/environment.dart';
|
||||||
|
import 'permission_resources.dart';
|
||||||
|
|
||||||
/// 권한 체크를 위한 액션 종류.
|
/// 권한 체크를 위한 액션 종류.
|
||||||
enum PermissionAction { view, create, edit, delete, restore, approve }
|
enum PermissionAction { view, create, edit, delete, restore, approve }
|
||||||
@@ -9,16 +10,20 @@ enum PermissionAction { view, create, edit, delete, restore, approve }
|
|||||||
class PermissionManager extends ChangeNotifier {
|
class PermissionManager extends ChangeNotifier {
|
||||||
PermissionManager({Map<String, Set<PermissionAction>>? overrides}) {
|
PermissionManager({Map<String, Set<PermissionAction>>? overrides}) {
|
||||||
if (overrides != null) {
|
if (overrides != null) {
|
||||||
_overrides.addAll(overrides);
|
updateOverrides(overrides);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 리소스별 임시 권한 집합을 보관한다.
|
/// 리소스별 임시 권한 집합을 보관한다.
|
||||||
final Map<String, Set<PermissionAction>> _overrides = {};
|
final Map<String, Set<PermissionAction>> _overrides = {};
|
||||||
|
|
||||||
|
/// 서버에서 내려받은 실제 권한 집합.
|
||||||
|
final Map<String, Set<PermissionAction>> _serverPermissions = {};
|
||||||
|
|
||||||
/// 지정한 리소스/행동이 허용되는지 여부를 반환한다.
|
/// 지정한 리소스/행동이 허용되는지 여부를 반환한다.
|
||||||
bool can(String resource, PermissionAction action) {
|
bool can(String resource, PermissionAction action) {
|
||||||
final override = _overrides[resource];
|
final key = _normalize(resource);
|
||||||
|
final override = _overrides[key];
|
||||||
if (override != null) {
|
if (override != null) {
|
||||||
// View 권한은 최소 접근을 허용하기 위해 별도로 처리한다.
|
// View 권한은 최소 접근을 허용하기 위해 별도로 처리한다.
|
||||||
if (override.contains(PermissionAction.view) &&
|
if (override.contains(PermissionAction.view) &&
|
||||||
@@ -27,16 +32,50 @@ class PermissionManager extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
return override.contains(action);
|
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<String, Set<PermissionAction>> overrides) {
|
void updateOverrides(Map<String, Set<PermissionAction>> overrides) {
|
||||||
_overrides
|
_overrides
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(overrides);
|
..addAll(
|
||||||
|
overrides.map((key, value) => MapEntry(_normalize(key), value.toSet())),
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 서버에서 내려온 권한 정보를 적용한다.
|
||||||
|
void applyServerPermissions(Map<String, Set<PermissionAction>> 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 위젯.
|
/// 위젯 트리에 [PermissionManager]를 전달하는 Inherited 위젯.
|
||||||
|
|||||||
83
test/core/network/api_error_test.dart
Normal file
83
test/core/network/api_error_test.dart
Normal file
@@ -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<dynamic>(
|
||||||
|
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<dynamic>(
|
||||||
|
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<dynamic> 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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
107
test/core/network/failure_parser_test.dart
Normal file
107
test/core/network/failure_parser_test.dart
Normal file
@@ -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<dynamic>(
|
||||||
|
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<dynamic>(
|
||||||
|
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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,73 +1,96 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||||
|
import 'package:superport_v2/core/permissions/permission_resources.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('PermissionManager', () {
|
group('PermissionManager', () {
|
||||||
test('falls back to environment permissions when no override', () {
|
test('서버 권한을 적용하면 해당 리소스 권한이 설정된다', () {
|
||||||
final manager = PermissionManager();
|
final manager = PermissionManager();
|
||||||
final allowed = manager.can('/any', PermissionAction.view);
|
|
||||||
expect(allowed, isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('respects overrides', () {
|
manager.applyServerPermissions({
|
||||||
final manager = PermissionManager(
|
PermissionResources.stockTransactions: {
|
||||||
overrides: {
|
PermissionAction.view,
|
||||||
'/inventory/inbound': {
|
PermissionAction.create,
|
||||||
PermissionAction.view,
|
|
||||||
PermissionAction.create,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
expect(manager.can('/inventory/inbound', PermissionAction.view), isTrue);
|
|
||||||
expect(
|
expect(
|
||||||
manager.can('/inventory/inbound', PermissionAction.create),
|
manager.can(
|
||||||
|
PermissionResources.stockTransactions,
|
||||||
|
PermissionAction.view,
|
||||||
|
),
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(
|
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,
|
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
48
test/core/permissions/permission_resources_test.dart
Normal file
48
test/core/permissions/permission_resources_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user