chore: 통합 테스트 환경과 보고서 리모트 구성
This commit is contained in:
97
test/core/network/api_client_test.dart
Normal file
97
test/core/network/api_client_test.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:superport_v2/core/network/api_client.dart';
|
||||
import 'package:superport_v2/core/network/api_error.dart';
|
||||
|
||||
class _MockDio extends Mock implements Dio {}
|
||||
|
||||
class _MockApiErrorMapper extends Mock implements ApiErrorMapper {}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
registerFallbackValue(RequestOptions(path: '/fallback'));
|
||||
registerFallbackValue(Options());
|
||||
registerFallbackValue(CancelToken());
|
||||
});
|
||||
|
||||
group('ApiClient', () {
|
||||
late Dio dio;
|
||||
late ApiErrorMapper mapper;
|
||||
late ApiClient client;
|
||||
|
||||
setUp(() {
|
||||
dio = _MockDio();
|
||||
mapper = _MockApiErrorMapper();
|
||||
client = ApiClient(dio: dio, errorMapper: mapper);
|
||||
});
|
||||
|
||||
test('성공 응답을 반환한다', () async {
|
||||
final requestOptions = RequestOptions(path: '/vendors');
|
||||
final response = Response<Map<String, dynamic>>(
|
||||
data: {'data': []},
|
||||
statusCode: 200,
|
||||
requestOptions: requestOptions,
|
||||
);
|
||||
|
||||
when(
|
||||
() => dio.get<Map<String, dynamic>>(
|
||||
any(),
|
||||
queryParameters: any(named: 'queryParameters'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).thenAnswer((_) async => response);
|
||||
|
||||
final result = await client.get<Map<String, dynamic>>('/vendors');
|
||||
|
||||
expect(result, same(response));
|
||||
verify(
|
||||
() => dio.get<Map<String, dynamic>>(
|
||||
any(),
|
||||
queryParameters: any(named: 'queryParameters'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('DioException을 ApiException으로 매핑한다', () async {
|
||||
final requestOptions = RequestOptions(path: '/vendors');
|
||||
final dioError = DioException(
|
||||
requestOptions: requestOptions,
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 400,
|
||||
data: {'message': '잘못된 요청입니다.'},
|
||||
),
|
||||
);
|
||||
final expected = ApiException(
|
||||
code: ApiErrorCode.badRequest,
|
||||
message: '잘못된 요청입니다.',
|
||||
statusCode: 400,
|
||||
cause: dioError,
|
||||
);
|
||||
|
||||
when(
|
||||
() => dio.get<Map<String, dynamic>>(
|
||||
any(),
|
||||
queryParameters: any(named: 'queryParameters'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).thenThrow(dioError);
|
||||
|
||||
when(() => mapper.map(dioError)).thenReturn(expected);
|
||||
|
||||
expect(
|
||||
() => client.get<Map<String, dynamic>>('/vendors'),
|
||||
throwsA(same(expected)),
|
||||
);
|
||||
|
||||
verify(() => mapper.map(dioError)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
122
test/core/network/auth_interceptor_test.dart
Normal file
122
test/core/network/auth_interceptor_test.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:superport_v2/core/network/interceptors/auth_interceptor.dart';
|
||||
import 'package:superport_v2/core/services/token_storage.dart';
|
||||
|
||||
class _MockTokenStorage extends Mock implements TokenStorage {}
|
||||
|
||||
class _MockDio extends Mock implements Dio {}
|
||||
|
||||
class _CapturingErrorHandler extends ErrorInterceptorHandler {
|
||||
Response<dynamic>? resolved;
|
||||
DioException? forwarded;
|
||||
|
||||
@override
|
||||
void resolve(Response<dynamic> response) {
|
||||
resolved = response;
|
||||
}
|
||||
|
||||
@override
|
||||
void next(DioException err) {
|
||||
forwarded = err;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
registerFallbackValue(RequestOptions(path: '/fallback'));
|
||||
});
|
||||
|
||||
group('AuthInterceptor', () {
|
||||
late TokenStorage storage;
|
||||
late Dio dio;
|
||||
|
||||
setUp(() {
|
||||
storage = _MockTokenStorage();
|
||||
dio = _MockDio();
|
||||
when(() => storage.writeAccessToken(any())).thenAnswer((_) async {});
|
||||
when(() => storage.writeRefreshToken(any())).thenAnswer((_) async {});
|
||||
when(() => storage.clear()).thenAnswer((_) async {});
|
||||
});
|
||||
|
||||
test('401 응답 시 토큰을 갱신하고 요청을 재시도한다', () async {
|
||||
when(
|
||||
() => storage.readAccessToken(),
|
||||
).thenAnswer((_) async => 'renewed-access');
|
||||
when(() => dio.fetch<dynamic>(any())).thenAnswer((invocation) async {
|
||||
final options = invocation.positionalArguments.first as RequestOptions;
|
||||
return Response<dynamic>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: {'ok': true},
|
||||
);
|
||||
});
|
||||
|
||||
final interceptor = AuthInterceptor(
|
||||
tokenStorage: storage,
|
||||
dio: dio,
|
||||
onRefresh: () async => const TokenPair(
|
||||
accessToken: 'renewed-access',
|
||||
refreshToken: 'renewed-refresh',
|
||||
),
|
||||
);
|
||||
|
||||
final requestOptions = RequestOptions(path: '/approvals');
|
||||
requestOptions.headers['Authorization'] = 'Bearer legacy-token';
|
||||
final error = DioException(
|
||||
requestOptions: requestOptions,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 401,
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
final handler = _CapturingErrorHandler();
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
expect(handler.forwarded, isNull);
|
||||
expect(handler.resolved, isNotNull);
|
||||
expect(
|
||||
requestOptions.headers['Authorization'],
|
||||
equals('Bearer renewed-access'),
|
||||
);
|
||||
verify(() => storage.writeAccessToken('renewed-access')).called(1);
|
||||
verify(() => storage.writeRefreshToken('renewed-refresh')).called(1);
|
||||
verifyNever(() => storage.clear());
|
||||
verify(() => dio.fetch<dynamic>(any())).called(1);
|
||||
});
|
||||
|
||||
test('토큰 갱신 실패 시 저장소를 초기화하고 오류를 전달한다', () async {
|
||||
when(
|
||||
() => storage.readAccessToken(),
|
||||
).thenAnswer((_) async => 'legacy-token');
|
||||
|
||||
final interceptor = AuthInterceptor(
|
||||
tokenStorage: storage,
|
||||
dio: dio,
|
||||
onRefresh: () async => null,
|
||||
);
|
||||
|
||||
final requestOptions = RequestOptions(path: '/approvals');
|
||||
final error = DioException(
|
||||
requestOptions: requestOptions,
|
||||
response: Response<dynamic>(
|
||||
requestOptions: requestOptions,
|
||||
statusCode: 401,
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
final handler = _CapturingErrorHandler();
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
expect(handler.resolved, isNull);
|
||||
expect(handler.forwarded, same(error));
|
||||
verify(() => storage.clear()).called(1);
|
||||
verifyNever(() => dio.fetch<dynamic>(any()));
|
||||
});
|
||||
});
|
||||
}
|
||||
190
test/features/login/presentation/pages/login_page_test.dart
Normal file
190
test/features/login/presentation/pages/login_page_test.dart
Normal file
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||
import 'package:superport_v2/core/permissions/permission_resources.dart';
|
||||
import 'package:superport_v2/features/login/presentation/pages/login_page.dart';
|
||||
import 'package:superport_v2/features/masters/group/domain/entities/group.dart';
|
||||
import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart';
|
||||
import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart';
|
||||
import 'package:superport_v2/features/masters/group_permission/domain/repositories/group_permission_repository.dart';
|
||||
|
||||
class _MockGroupRepository extends Mock implements GroupRepository {}
|
||||
|
||||
class _MockGroupPermissionRepository extends Mock
|
||||
implements GroupPermissionRepository {}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
tearDown(() async {
|
||||
await GetIt.I.reset();
|
||||
});
|
||||
|
||||
testWidgets('로그인 성공 시 권한 동기화 후 대시보드로 이동한다', (tester) async {
|
||||
final groupRepository = _MockGroupRepository();
|
||||
final permissionRepository = _MockGroupPermissionRepository();
|
||||
GetIt.I.registerSingleton<GroupRepository>(groupRepository);
|
||||
GetIt.I.registerSingleton<GroupPermissionRepository>(permissionRepository);
|
||||
|
||||
when(
|
||||
() => groupRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isDefault: any(named: 'isDefault'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includePermissions: any(named: 'includePermissions'),
|
||||
includeEmployees: any(named: 'includeEmployees'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final items = [Group(id: 1, groupName: '관리자')];
|
||||
return PaginatedResult<Group>(
|
||||
items: items,
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
total: 1,
|
||||
);
|
||||
});
|
||||
|
||||
when(
|
||||
() => permissionRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
groupId: any(named: 'groupId'),
|
||||
menuId: any(named: 'menuId'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includeDeleted: any(named: 'includeDeleted'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<GroupPermission>(
|
||||
items: [
|
||||
GroupPermission(
|
||||
id: 1,
|
||||
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 10,
|
||||
menuCode: 'INBOUND',
|
||||
menuName: '입고',
|
||||
path: '/inventory/inbound',
|
||||
),
|
||||
canCreate: true,
|
||||
canRead: true,
|
||||
),
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
|
||||
final manager = PermissionManager();
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: loginRoutePath,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: loginRoutePath,
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: dashboardRoutePath,
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: Center(child: Text('대시보드'))),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
PermissionScope(
|
||||
manager: manager,
|
||||
child: ShadApp.router(routerConfig: router),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(EditableText).at(0), 'user@superport');
|
||||
await tester.enterText(find.byType(EditableText).at(1), 'password');
|
||||
await tester.tap(find.text('로그인'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
router.routerDelegate.currentConfiguration.last.matchedLocation,
|
||||
dashboardRoutePath,
|
||||
);
|
||||
expect(
|
||||
manager.can(PermissionResources.stockTransactions, PermissionAction.view),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.create,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('권한 동기화 실패 시 오류 메시지를 표시한다', (tester) async {
|
||||
final groupRepository = _MockGroupRepository();
|
||||
final permissionRepository = _MockGroupPermissionRepository();
|
||||
GetIt.I.registerSingleton<GroupRepository>(groupRepository);
|
||||
GetIt.I.registerSingleton<GroupPermissionRepository>(permissionRepository);
|
||||
|
||||
when(
|
||||
() => groupRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isDefault: any(named: 'isDefault'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includePermissions: any(named: 'includePermissions'),
|
||||
includeEmployees: any(named: 'includeEmployees'),
|
||||
),
|
||||
).thenThrow(Exception('network error'));
|
||||
|
||||
final manager = PermissionManager();
|
||||
final router = GoRouter(
|
||||
initialLocation: loginRoutePath,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: loginRoutePath,
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: dashboardRoutePath,
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: Center(child: Text('대시보드'))),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
PermissionScope(
|
||||
manager: manager,
|
||||
child: ShadApp.router(routerConfig: router),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(EditableText).at(0), 'user@superport');
|
||||
await tester.enterText(find.byType(EditableText).at(1), 'password');
|
||||
await tester.tap(find.text('로그인'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), findsOneWidget);
|
||||
expect(
|
||||
router.routerDelegate.currentConfiguration.last.matchedLocation,
|
||||
loginRoutePath,
|
||||
);
|
||||
expect(
|
||||
manager.can(PermissionResources.stockTransactions, PermissionAction.view),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:superport_v2/core/network/api_client.dart';
|
||||
import 'package:superport_v2/features/reporting/data/repositories/reporting_repository_remote.dart';
|
||||
import 'package:superport_v2/features/reporting/domain/entities/report_export_format.dart';
|
||||
import 'package:superport_v2/features/reporting/domain/entities/report_export_request.dart';
|
||||
|
||||
class _MockApiClient extends Mock implements ApiClient {}
|
||||
|
||||
void main() {
|
||||
late ApiClient apiClient;
|
||||
late ReportingRepositoryRemote repository;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(Options());
|
||||
registerFallbackValue(CancelToken());
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
apiClient = _MockApiClient();
|
||||
repository = ReportingRepositoryRemote(apiClient: apiClient);
|
||||
});
|
||||
|
||||
Response<Uint8List> jsonResponse(String path, Map<String, dynamic> body) {
|
||||
final bytes = Uint8List.fromList(jsonEncode(body).codeUnits);
|
||||
return Response<Uint8List>(
|
||||
data: bytes,
|
||||
headers: Headers.fromMap({
|
||||
'content-type': ['application/json'],
|
||||
}),
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 200,
|
||||
);
|
||||
}
|
||||
|
||||
Response<Uint8List> binaryResponse(
|
||||
String path, {
|
||||
required List<int> bytes,
|
||||
required String filename,
|
||||
required String mimeType,
|
||||
}) {
|
||||
return Response<Uint8List>(
|
||||
data: Uint8List.fromList(bytes),
|
||||
headers: Headers.fromMap({
|
||||
'content-type': [mimeType],
|
||||
'content-disposition': ['attachment; filename="$filename"'],
|
||||
}),
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 200,
|
||||
);
|
||||
}
|
||||
|
||||
test('exportTransactions는 download_url을 파싱한다', () async {
|
||||
const path = '/api/v1/reports/transactions/export';
|
||||
when(
|
||||
() => apiClient.get<Uint8List>(
|
||||
path,
|
||||
query: any(named: 'query'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => jsonResponse(path, {
|
||||
'data': {
|
||||
'download_url': 'https://example.com/report.xlsx',
|
||||
'filename': 'report.xlsx',
|
||||
'mime_type':
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'expires_at': '2025-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
final request = ReportExportRequest(
|
||||
from: DateTime(2024, 1, 1),
|
||||
to: DateTime(2024, 1, 31),
|
||||
format: ReportExportFormat.xlsx,
|
||||
transactionTypeId: 3,
|
||||
statusId: 1,
|
||||
warehouseId: 9,
|
||||
);
|
||||
|
||||
final result = await repository.exportTransactions(request);
|
||||
|
||||
final captured = verify(
|
||||
() => apiClient.get<Uint8List>(
|
||||
captureAny(),
|
||||
query: captureAny(named: 'query'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).captured;
|
||||
|
||||
expect(captured.first, equals(path));
|
||||
final query = captured[1] as Map<String, dynamic>;
|
||||
expect(query['from'], request.from.toIso8601String());
|
||||
expect(query['to'], request.to.toIso8601String());
|
||||
expect(query['format'], 'xlsx');
|
||||
expect(query['type_id'], 3);
|
||||
expect(query['status_id'], 1);
|
||||
expect(query['warehouse_id'], 9);
|
||||
|
||||
expect(result.downloadUrl.toString(), 'https://example.com/report.xlsx');
|
||||
expect(result.filename, 'report.xlsx');
|
||||
expect(
|
||||
result.mimeType,
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
expect(result.expiresAt, DateTime.parse('2025-01-01T00:00:00Z'));
|
||||
expect(result.hasDownloadUrl, isTrue);
|
||||
expect(result.hasBytes, isFalse);
|
||||
});
|
||||
|
||||
test('exportApprovals는 바이너리 응답을 처리한다', () async {
|
||||
const path = '/api/v1/reports/approvals/export';
|
||||
when(
|
||||
() => apiClient.get<Uint8List>(
|
||||
path,
|
||||
query: any(named: 'query'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => binaryResponse(
|
||||
path,
|
||||
bytes: [1, 2, 3],
|
||||
filename: 'approval.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
),
|
||||
);
|
||||
|
||||
final request = ReportExportRequest(
|
||||
from: DateTime(2024, 2, 1),
|
||||
to: DateTime(2024, 2, 15),
|
||||
format: ReportExportFormat.pdf,
|
||||
statusId: 5,
|
||||
);
|
||||
|
||||
final result = await repository.exportApprovals(request);
|
||||
|
||||
expect(result.hasBytes, isTrue);
|
||||
expect(result.bytes, isNotNull);
|
||||
expect(result.filename, 'approval.pdf');
|
||||
expect(result.mimeType, 'application/pdf');
|
||||
expect(result.hasDownloadUrl, isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user