feat(inventory): 재고 현황 요약/상세 플로우를 릴리스
- lib/features/inventory/summary 계층과 warehouse select 위젯을 추가해 목록/상세, 자동 새로고침, 필터, 상세 시트를 구현 - PermissionBootstrapper, scope 파서, 라우트 가드로 inventory.view 기반 권한 부여와 메뉴 노출을 통합(lib/core, lib/main.dart 등) - Inventory Summary API/QA/Audit 문서와 PR 템플릿, CHANGELOG를 신규 스펙과 검증 커맨드로 업데이트 - DTO 직렬화 의존성을 추가하고 Golden·Widget·단위 테스트를 작성했으며 flutter analyze / flutter test --coverage를 통과
This commit is contained in:
@@ -92,5 +92,30 @@ void main() {
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('scope 리소스도 권한 검사에 활용된다', () {
|
||||
final manager = PermissionManager();
|
||||
manager.applyServerPermissions({
|
||||
PermissionResources.inventoryScope: {PermissionAction.view},
|
||||
});
|
||||
|
||||
expect(
|
||||
manager.can(PermissionResources.inventoryScope, PermissionAction.view),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(PermissionResources.inventoryScope, PermissionAction.edit),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('scope 권한이 없으면 기본적으로 거부된다', () {
|
||||
final manager = PermissionManager();
|
||||
|
||||
expect(
|
||||
manager.can(PermissionResources.inventoryScope, PermissionAction.view),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,11 +170,7 @@ void main() {
|
||||
|
||||
record = historyRepository.listResult!.items.first;
|
||||
|
||||
user = const AuthenticatedUser(
|
||||
id: 42,
|
||||
name: '결재자',
|
||||
employeeNo: 'E042',
|
||||
);
|
||||
user = const AuthenticatedUser(id: 42, name: '결재자', employeeNo: 'E042');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -199,148 +195,143 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'showApprovalHistoryDetailDialog 결재 요약과 타임라인을 표시한다',
|
||||
(tester) async {
|
||||
await openDialog(tester);
|
||||
testWidgets('showApprovalHistoryDetailDialog 결재 요약과 타임라인을 표시한다', (
|
||||
tester,
|
||||
) async {
|
||||
await openDialog(tester);
|
||||
|
||||
expect(find.text('결재 이력 상세'), findsOneWidget);
|
||||
expect(find.textContaining('결재번호 ${record.approvalNo}'), findsWidgets);
|
||||
expect(find.text('상태 타임라인'), findsOneWidget);
|
||||
expect(find.text('감사 로그'), findsOneWidget);
|
||||
expect(find.text('결재 이력 상세'), findsOneWidget);
|
||||
expect(find.textContaining('결재번호 ${record.approvalNo}'), findsWidgets);
|
||||
expect(find.text('상태 타임라인'), findsOneWidget);
|
||||
expect(find.text('감사 로그'), findsOneWidget);
|
||||
|
||||
expect(
|
||||
find.textContaining(
|
||||
'상신자 ${sampleApproval.requester.name} (${sampleApproval.requester.employeeNo})',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('총 ${sampleApproval.steps.length}단계'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('승인'), findsWidgets);
|
||||
expect(
|
||||
find.textContaining(
|
||||
'상신자 ${sampleApproval.requester.name} (${sampleApproval.requester.employeeNo})',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('총 ${sampleApproval.steps.length}단계'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('승인'), findsWidgets);
|
||||
|
||||
expect(approvalRepository.listHistoryCalls, isNotEmpty);
|
||||
},
|
||||
);
|
||||
expect(approvalRepository.listHistoryCalls, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'회수 버튼을 누르면 recallApproval이 호출되어 감사 로그가 새로고침된다',
|
||||
(tester) async {
|
||||
await openDialog(tester);
|
||||
testWidgets('회수 버튼을 누르면 recallApproval이 호출되어 감사 로그가 새로고침된다', (tester) async {
|
||||
await openDialog(tester);
|
||||
|
||||
final recallButton = find.widgetWithText(ShadButton, '회수');
|
||||
expect(recallButton, findsOneWidget);
|
||||
await tester.ensureVisible(recallButton);
|
||||
await tester.tap(recallButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
final recallButton = find.widgetWithText(ShadButton, '회수');
|
||||
expect(recallButton, findsOneWidget);
|
||||
await tester.ensureVisible(recallButton);
|
||||
await tester.tap(recallButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final dialogFinder = find.ancestor(
|
||||
of: find.text('결재 회수'),
|
||||
matching: find.byType(SuperportDialog),
|
||||
);
|
||||
expect(dialogFinder, findsOneWidget);
|
||||
final dialogFinder = find.ancestor(
|
||||
of: find.text('결재 회수'),
|
||||
matching: find.byType(SuperportDialog),
|
||||
);
|
||||
expect(dialogFinder, findsOneWidget);
|
||||
|
||||
final memoField = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.byType(ShadTextarea),
|
||||
);
|
||||
expect(memoField, findsOneWidget);
|
||||
await tester.enterText(memoField, '긴급 회수');
|
||||
final memoField = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.byType(ShadTextarea),
|
||||
);
|
||||
expect(memoField, findsOneWidget);
|
||||
await tester.enterText(memoField, '긴급 회수');
|
||||
|
||||
final confirmButton = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.widgetWithText(ShadButton, '회수'),
|
||||
);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
final confirmButton = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.widgetWithText(ShadButton, '회수'),
|
||||
);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(approvalRepository.recallInputs, hasLength(1));
|
||||
expect(approvalRepository.recallInputs.first.note, '긴급 회수');
|
||||
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
|
||||
},
|
||||
);
|
||||
expect(approvalRepository.recallInputs, hasLength(1));
|
||||
expect(approvalRepository.recallInputs.first.note, '긴급 회수');
|
||||
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'재상신 버튼을 누르면 resubmitApproval이 호출되어 최신 단계 정보가 전달된다',
|
||||
(tester) async {
|
||||
final rejectedApproval = sampleApproval.copyWith(
|
||||
status: statusRejected,
|
||||
steps: sampleApproval.steps
|
||||
.map(
|
||||
(step) => step.stepOrder == 1
|
||||
? step.copyWith(
|
||||
status: statusRejected,
|
||||
decidedAt: DateTime(2024, 1, 10, 11),
|
||||
)
|
||||
: step,
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
final resubmittedStatus = ApprovalStatus(
|
||||
id: 4,
|
||||
name: '재상신',
|
||||
color: '#6366F1',
|
||||
isTerminal: false,
|
||||
);
|
||||
final resubmittedApproval = rejectedApproval.copyWith(
|
||||
status: resubmittedStatus,
|
||||
updatedAt: DateTime(2024, 1, 10, 13, 10),
|
||||
);
|
||||
testWidgets('재상신 버튼을 누르면 resubmitApproval이 호출되어 최신 단계 정보가 전달된다', (
|
||||
tester,
|
||||
) async {
|
||||
final rejectedApproval = sampleApproval.copyWith(
|
||||
status: statusRejected,
|
||||
steps: sampleApproval.steps
|
||||
.map(
|
||||
(step) => step.stepOrder == 1
|
||||
? step.copyWith(
|
||||
status: statusRejected,
|
||||
decidedAt: DateTime(2024, 1, 10, 11),
|
||||
)
|
||||
: step,
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
final resubmittedStatus = ApprovalStatus(
|
||||
id: 4,
|
||||
name: '재상신',
|
||||
color: '#6366F1',
|
||||
isTerminal: false,
|
||||
);
|
||||
final resubmittedApproval = rejectedApproval.copyWith(
|
||||
status: resubmittedStatus,
|
||||
updatedAt: DateTime(2024, 1, 10, 13, 10),
|
||||
);
|
||||
|
||||
approvalRepository
|
||||
..detail = rejectedApproval
|
||||
..resubmitResult = resubmittedApproval;
|
||||
record = record.copyWith(
|
||||
action: ApprovalAction(id: 33, name: '반려', code: 'reject'),
|
||||
toStatus: statusRejected,
|
||||
stepOrder: 2,
|
||||
);
|
||||
approvalRepository
|
||||
..detail = rejectedApproval
|
||||
..resubmitResult = resubmittedApproval;
|
||||
record = record.copyWith(
|
||||
action: ApprovalAction(id: 33, name: '반려', code: 'reject'),
|
||||
toStatus: statusRejected,
|
||||
stepOrder: 2,
|
||||
);
|
||||
|
||||
await openDialog(tester);
|
||||
await openDialog(tester);
|
||||
|
||||
final resubmitButton = find.widgetWithText(ShadButton, '재상신');
|
||||
expect(resubmitButton, findsOneWidget);
|
||||
await tester.ensureVisible(resubmitButton);
|
||||
await tester.tap(resubmitButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
final resubmitButton = find.widgetWithText(ShadButton, '재상신');
|
||||
expect(resubmitButton, findsOneWidget);
|
||||
await tester.ensureVisible(resubmitButton);
|
||||
await tester.tap(resubmitButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final dialogFinder = find.ancestor(
|
||||
of: find.text('결재 재상신'),
|
||||
matching: find.byType(SuperportDialog),
|
||||
);
|
||||
expect(dialogFinder, findsOneWidget);
|
||||
final dialogFinder = find.ancestor(
|
||||
of: find.text('결재 재상신'),
|
||||
matching: find.byType(SuperportDialog),
|
||||
);
|
||||
expect(dialogFinder, findsOneWidget);
|
||||
|
||||
final memoField = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.byType(ShadTextarea),
|
||||
);
|
||||
expect(memoField, findsOneWidget);
|
||||
await tester.enterText(memoField, '재상신 메모');
|
||||
final memoField = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.byType(ShadTextarea),
|
||||
);
|
||||
expect(memoField, findsOneWidget);
|
||||
await tester.enterText(memoField, '재상신 메모');
|
||||
|
||||
final confirmButton = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.widgetWithText(ShadButton, '재상신'),
|
||||
);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
final confirmButton = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.widgetWithText(ShadButton, '재상신'),
|
||||
);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(approvalRepository.resubmitInputs, hasLength(1));
|
||||
final input = approvalRepository.resubmitInputs.first;
|
||||
expect(input.note, '재상신 메모');
|
||||
expect(input.submission.steps.length, rejectedApproval.steps.length);
|
||||
expect(
|
||||
input.submission.steps.first.stepOrder,
|
||||
rejectedApproval.steps.first.stepOrder,
|
||||
);
|
||||
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
|
||||
},
|
||||
);
|
||||
expect(approvalRepository.resubmitInputs, hasLength(1));
|
||||
final input = approvalRepository.resubmitInputs.first;
|
||||
expect(input.note, '재상신 메모');
|
||||
expect(input.submission.steps.length, rejectedApproval.steps.length);
|
||||
expect(
|
||||
input.submission.steps.first.stepOrder,
|
||||
rejectedApproval.steps.first.stepOrder,
|
||||
);
|
||||
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeApprovalRepository implements ApprovalRepository {
|
||||
|
||||
71
test/features/auth/data/dtos/auth_session_dto_test.dart
Normal file
71
test/features/auth/data/dtos/auth_session_dto_test.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:superport_v2/features/auth/data/dtos/auth_session_dto.dart';
|
||||
|
||||
void main() {
|
||||
group('AuthSessionDto', () {
|
||||
test('permission_codes를 scope 권한으로 변환한다', () {
|
||||
final dto = AuthSessionDto.fromJson({
|
||||
'access_token': 'access',
|
||||
'refresh_token': 'refresh',
|
||||
'user': {'id': 1, 'name': '테스터'},
|
||||
'permission_codes': [
|
||||
'inventory.view',
|
||||
'scope:approval.manage',
|
||||
' APPROVAL.VIEW_ALL ',
|
||||
],
|
||||
});
|
||||
|
||||
final scopeResources = dto.permissions.map((p) => p.resource).where((r) {
|
||||
return r.startsWith('scope:');
|
||||
}).toSet();
|
||||
|
||||
expect(
|
||||
scopeResources,
|
||||
containsAll({
|
||||
'scope:inventory.view',
|
||||
'scope:approval.manage',
|
||||
'scope:approval.view_all',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('permission_scopes 응답도 scope 권한으로 적용한다', () {
|
||||
final dto = AuthSessionDto.fromJson({
|
||||
'access_token': 'access',
|
||||
'refresh_token': 'refresh',
|
||||
'user': {'id': 10, 'name': '권한계정'},
|
||||
'permissions': [
|
||||
{
|
||||
'resource': '/dashboard',
|
||||
'actions': ['view'],
|
||||
},
|
||||
],
|
||||
'permission_scopes': [
|
||||
{'scope_code': 'inventory.view'},
|
||||
{'code': 'approval.view_all'},
|
||||
{'scope': 'approval.approve'},
|
||||
],
|
||||
'group_permission_scopes': [
|
||||
'scope:report.export',
|
||||
{'name': 'report.view'},
|
||||
],
|
||||
});
|
||||
|
||||
final scopeResources = dto.permissions.map((p) => p.resource).where((r) {
|
||||
return r.startsWith('scope:');
|
||||
}).toSet();
|
||||
|
||||
expect(
|
||||
scopeResources,
|
||||
containsAll({
|
||||
'scope:inventory.view',
|
||||
'scope:approval.view_all',
|
||||
'scope:approval.approve',
|
||||
'scope:report.export',
|
||||
'scope:report.view',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,34 +1,24 @@
|
||||
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';
|
||||
import 'package:superport_v2/features/auth/domain/entities/auth_permission.dart';
|
||||
|
||||
void main() {
|
||||
group('AuthPermission.toPermissionMap', () {
|
||||
test('백엔드 표준 문자열을 프런트 권한으로 매핑한다', () {
|
||||
final permission = AuthPermission(
|
||||
resource: '/approvals',
|
||||
actions: ['read', 'update', 'approve'],
|
||||
group('AuthPermission', () {
|
||||
test('scope 리소스는 actions가 비어도 view 권한을 부여한다', () {
|
||||
const permission = AuthPermission(
|
||||
resource: 'scope:inventory.view',
|
||||
actions: [],
|
||||
);
|
||||
|
||||
final result = permission.toPermissionMap();
|
||||
final map = permission.toPermissionMap();
|
||||
|
||||
expect(result, contains('/approvals'));
|
||||
final actions = result['/approvals']!;
|
||||
expect(actions.contains(PermissionAction.view), isTrue);
|
||||
expect(actions.contains(PermissionAction.edit), isTrue);
|
||||
expect(actions.contains(PermissionAction.approve), isTrue);
|
||||
});
|
||||
|
||||
test('알 수 없는 문자열은 무시해 빈 권한으로 반환한다', () {
|
||||
final permission = AuthPermission(
|
||||
resource: '/dashboard',
|
||||
actions: ['unknown', 'legacy'],
|
||||
expect(map.length, 1);
|
||||
expect(
|
||||
map[PermissionResources.inventoryScope],
|
||||
contains(PermissionAction.view),
|
||||
);
|
||||
|
||||
final result = permission.toPermissionMap();
|
||||
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_detail.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_filters.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_product.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary_list_result.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_vendor.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/repositories/inventory_repository.dart';
|
||||
|
||||
class FakeInventoryRepository implements InventoryRepository {
|
||||
InventorySummaryListResult? summaryResult;
|
||||
InventoryDetail? detailResult;
|
||||
Object? summaryError;
|
||||
Object? detailError;
|
||||
InventorySummaryFilter? lastSummaryFilter;
|
||||
InventoryDetailFilter? lastDetailFilter;
|
||||
|
||||
@override
|
||||
Future<InventoryDetail> fetchDetail(
|
||||
int productId, {
|
||||
InventoryDetailFilter? filter,
|
||||
}) async {
|
||||
lastDetailFilter = filter;
|
||||
if (detailError != null) {
|
||||
throw detailError!;
|
||||
}
|
||||
return detailResult ?? buildDetail(productId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InventorySummaryListResult> listSummaries({
|
||||
InventorySummaryFilter? filter,
|
||||
}) async {
|
||||
lastSummaryFilter = filter;
|
||||
if (summaryError != null) {
|
||||
throw summaryError!;
|
||||
}
|
||||
return summaryResult ?? buildSummaryResult();
|
||||
}
|
||||
}
|
||||
|
||||
InventorySummaryListResult buildSummaryResult() {
|
||||
final product = InventoryProduct(
|
||||
id: 1,
|
||||
code: 'P-1',
|
||||
name: '장비',
|
||||
vendor: const InventoryVendor(id: 9, name: '벤더'),
|
||||
);
|
||||
final refreshedAt = DateTime.utc(2025, 1, 1, 12);
|
||||
final summary = InventorySummary(
|
||||
product: product,
|
||||
totalQuantity: 10,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(
|
||||
warehouse: const InventoryWarehouse(id: 1, code: 'WH-1', name: '본사'),
|
||||
quantity: 10,
|
||||
),
|
||||
],
|
||||
recentEvent: null,
|
||||
updatedAt: DateTime.utc(2025, 1, 1),
|
||||
lastRefreshedAt: refreshedAt,
|
||||
);
|
||||
final paginated = PaginatedResult<InventorySummary>(
|
||||
items: [summary],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
);
|
||||
return InventorySummaryListResult(
|
||||
result: paginated,
|
||||
lastRefreshedAt: refreshedAt,
|
||||
);
|
||||
}
|
||||
|
||||
InventoryDetail buildDetail(int productId) {
|
||||
final product = InventoryProduct(
|
||||
id: productId,
|
||||
code: 'P-$productId',
|
||||
name: '제품$productId',
|
||||
vendor: const InventoryVendor(id: 9, name: '벤더'),
|
||||
);
|
||||
return InventoryDetail(
|
||||
product: product,
|
||||
totalQuantity: 5,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(
|
||||
warehouse: const InventoryWarehouse(id: 1, code: 'WH-1', name: '본사'),
|
||||
quantity: 5,
|
||||
),
|
||||
],
|
||||
recentEvents: const [],
|
||||
updatedAt: DateTime.utc(2025, 1, 2),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 2),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/application/inventory_service.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_filters.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/presentation/controllers/inventory_detail_controller.dart';
|
||||
|
||||
import 'fake_inventory_repository.dart';
|
||||
|
||||
void main() {
|
||||
group('InventoryDetailController', () {
|
||||
late FakeInventoryRepository repository;
|
||||
late InventoryDetailController controller;
|
||||
|
||||
setUp(() {
|
||||
repository = FakeInventoryRepository();
|
||||
controller = InventoryDetailController(
|
||||
service: InventoryService(repository: repository),
|
||||
);
|
||||
});
|
||||
|
||||
test('fetch는 상세 정보를 로드하고 캐시한다', () async {
|
||||
await controller.fetch(1);
|
||||
|
||||
expect(controller.detailOf(1), isNotNull);
|
||||
expect(controller.isLoading(1), isFalse);
|
||||
expect(controller.errorOf(1), isNull);
|
||||
expect(repository.lastDetailFilter, isNotNull);
|
||||
});
|
||||
|
||||
test('동일 필터로 재요청 시 추가 호출을 건너뛴다', () async {
|
||||
await controller.fetch(2);
|
||||
repository.detailError = Exception('should not be thrown');
|
||||
|
||||
await controller.fetch(2);
|
||||
|
||||
expect(controller.errorOf(2), isNull);
|
||||
});
|
||||
|
||||
test('필터 변경 시 강제로 다시 조회한다', () async {
|
||||
await controller.fetch(3);
|
||||
repository.detailError = Exception('boom');
|
||||
|
||||
await controller.updateEventLimit(3, 50);
|
||||
|
||||
expect(controller.errorOf(3), contains('boom'));
|
||||
expect(repository.lastDetailFilter?.eventLimit, 50);
|
||||
});
|
||||
|
||||
test('오류를 명시적으로 초기화할 수 있다', () async {
|
||||
repository.detailError = Exception('boom');
|
||||
await controller.fetch(5, filter: const InventoryDetailFilter());
|
||||
|
||||
expect(controller.errorOf(5), isNotNull);
|
||||
|
||||
controller.clearError(5);
|
||||
|
||||
expect(controller.errorOf(5), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/application/inventory_service.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/presentation/controllers/inventory_summary_controller.dart';
|
||||
|
||||
import 'fake_inventory_repository.dart';
|
||||
|
||||
void main() {
|
||||
group('InventorySummaryController', () {
|
||||
late FakeInventoryRepository repository;
|
||||
late InventorySummaryController controller;
|
||||
|
||||
setUp(() {
|
||||
repository = FakeInventoryRepository();
|
||||
controller = InventorySummaryController(
|
||||
service: InventoryService(repository: repository),
|
||||
);
|
||||
});
|
||||
|
||||
test('fetch 저장 시 결과와 페이징 상태를 갱신한다', () async {
|
||||
repository.summaryResult = buildSummaryResult();
|
||||
|
||||
await controller.fetch();
|
||||
|
||||
expect(controller.result, isNotNull);
|
||||
expect(controller.result!.items, isNotEmpty);
|
||||
expect(controller.isLoading, isFalse);
|
||||
expect(controller.errorMessage, isNull);
|
||||
expect(repository.lastSummaryFilter?.page, 1);
|
||||
expect(controller.lastRefreshedAt, DateTime.utc(2025, 1, 1, 12));
|
||||
});
|
||||
|
||||
test('쿼리/정렬/필터 업데이트가 상태에 반영된다', () {
|
||||
controller
|
||||
..updateQuery(' camera ')
|
||||
..updateProductName('렌즈')
|
||||
..updateVendorName('슈퍼')
|
||||
..updateWarehouse(7)
|
||||
..toggleIncludeEmpty(true)
|
||||
..updateSort('total_quantity', order: 'asc')
|
||||
..updatePageSize(30);
|
||||
|
||||
expect(controller.query, 'camera');
|
||||
expect(controller.productName, '렌즈');
|
||||
expect(controller.vendorName, '슈퍼');
|
||||
expect(controller.warehouseId, 7);
|
||||
expect(controller.includeEmpty, isTrue);
|
||||
expect(controller.sort, 'total_quantity');
|
||||
expect(controller.order, 'asc');
|
||||
expect(controller.pageSize, 30);
|
||||
});
|
||||
|
||||
test('요청 실패 시 오류 메시지를 저장한다', () async {
|
||||
repository.summaryError = Exception('boom');
|
||||
|
||||
await controller.fetch();
|
||||
|
||||
expect(controller.errorMessage, contains('boom'));
|
||||
expect(controller.isLoading, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.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/features/inventory/summary/application/inventory_service.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_detail.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_event.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_product.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary_list_result.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_vendor.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/repositories/inventory_repository.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_filters.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/presentation/pages/inventory_summary_page.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
||||
|
||||
class _MockInventoryRepository extends Mock implements InventoryRepository {}
|
||||
|
||||
class _MockWarehouseRepository extends Mock implements WarehouseRepository {}
|
||||
|
||||
Widget _buildApp(Widget child) {
|
||||
return MaterialApp(
|
||||
home: ShadTheme(
|
||||
data: ShadThemeData(
|
||||
colorScheme: const ShadSlateColorScheme.light(),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
child: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InventorySummaryListResult _buildSummaryResult() {
|
||||
final warehouse = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
|
||||
final summary = InventorySummary(
|
||||
product: InventoryProduct(
|
||||
id: 10,
|
||||
code: 'INV-10',
|
||||
name: '테스트 장비',
|
||||
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
|
||||
),
|
||||
totalQuantity: 120,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(warehouse: warehouse, quantity: 80),
|
||||
InventoryWarehouseBalance(
|
||||
warehouse: InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고'),
|
||||
quantity: 40,
|
||||
),
|
||||
],
|
||||
recentEvent: InventoryEvent(
|
||||
eventId: 900,
|
||||
eventKind: 'receipt',
|
||||
eventLabel: '입고',
|
||||
deltaQuantity: 30,
|
||||
occurredAt: DateTime.utc(2025, 1, 3, 9, 0),
|
||||
warehouse: warehouse,
|
||||
),
|
||||
updatedAt: DateTime.utc(2025, 1, 3, 9, 15),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
|
||||
);
|
||||
return InventorySummaryListResult(
|
||||
result: PaginatedResult<InventorySummary>(
|
||||
items: [summary],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
),
|
||||
lastRefreshedAt: summary.lastRefreshedAt,
|
||||
);
|
||||
}
|
||||
|
||||
InventoryDetail _buildDetail() {
|
||||
final warehouse1 = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
|
||||
final warehouse2 = InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고');
|
||||
return InventoryDetail(
|
||||
product: InventoryProduct(
|
||||
id: 10,
|
||||
code: 'INV-10',
|
||||
name: '테스트 장비',
|
||||
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
|
||||
),
|
||||
totalQuantity: 120,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(warehouse: warehouse1, quantity: 80),
|
||||
InventoryWarehouseBalance(warehouse: warehouse2, quantity: 40),
|
||||
],
|
||||
recentEvents: [
|
||||
InventoryEvent(
|
||||
eventId: 901,
|
||||
eventKind: 'receipt',
|
||||
eventLabel: '입고',
|
||||
deltaQuantity: 20,
|
||||
occurredAt: DateTime.utc(2025, 1, 3, 9, 10),
|
||||
warehouse: warehouse1,
|
||||
),
|
||||
],
|
||||
updatedAt: DateTime.utc(2025, 1, 3, 9, 20),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
|
||||
);
|
||||
}
|
||||
|
||||
void _registerDependencies({
|
||||
required InventoryRepository inventoryRepository,
|
||||
required WarehouseRepository warehouseRepository,
|
||||
}) {
|
||||
GetIt.I.registerSingleton<InventoryService>(
|
||||
InventoryService(repository: inventoryRepository),
|
||||
);
|
||||
GetIt.I.registerSingleton<WarehouseRepository>(warehouseRepository);
|
||||
}
|
||||
|
||||
void _stubWarehouseList(_MockWarehouseRepository repository) {
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includeZipcode: any(named: 'includeZipcode'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final page = invocation.namedArguments[const Symbol('page')] as int? ?? 1;
|
||||
final items = page == 1
|
||||
? [Warehouse(id: 1, warehouseCode: 'WH-001', warehouseName: '본사 창고')]
|
||||
: const <Warehouse>[];
|
||||
return PaginatedResult<Warehouse>(
|
||||
items: items,
|
||||
page: page,
|
||||
pageSize:
|
||||
invocation.namedArguments[const Symbol('pageSize')] as int? ?? 20,
|
||||
total: items.length,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpInventoryPage(WidgetTester tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 1200));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(
|
||||
routeUri: Uri(path: '/inventory/summary'),
|
||||
debugRowHeight: 200,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(const InventorySummaryFilter());
|
||||
registerFallbackValue(const InventoryDetailFilter());
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => _buildSummaryResult());
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => _buildDetail());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await binding.setSurfaceSize(null);
|
||||
await GetIt.I.reset();
|
||||
});
|
||||
|
||||
testWidgets('Inventory summary page matches golden', (tester) async {
|
||||
await _pumpInventoryPage(tester);
|
||||
|
||||
await expectLater(
|
||||
find.byType(InventorySummaryPage),
|
||||
matchesGoldenFile('goldens/inventory_summary_page_default.png'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Inventory detail sheet matches golden', (tester) async {
|
||||
await _pumpInventoryPage(tester);
|
||||
await tester.tap(find.text('테스트 장비'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await expectLater(
|
||||
find.byType(InventorySummaryPage),
|
||||
matchesGoldenFile('goldens/inventory_summary_detail_sheet.png'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.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/features/inventory/summary/application/inventory_service.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_counterparty.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_detail.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_event.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_filters.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_product.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary_list_result.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_vendor.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/repositories/inventory_repository.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/presentation/pages/inventory_summary_page.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
||||
|
||||
class _MockInventoryRepository extends Mock implements InventoryRepository {}
|
||||
|
||||
class _MockWarehouseRepository extends Mock implements WarehouseRepository {}
|
||||
|
||||
Widget _buildApp(Widget child) {
|
||||
return MaterialApp(
|
||||
home: ShadTheme(
|
||||
data: ShadThemeData(
|
||||
colorScheme: const ShadSlateColorScheme.light(),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
child: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _registerDependencies({
|
||||
required InventoryRepository inventoryRepository,
|
||||
required WarehouseRepository warehouseRepository,
|
||||
}) {
|
||||
GetIt.I.registerSingleton<InventoryService>(
|
||||
InventoryService(repository: inventoryRepository),
|
||||
);
|
||||
GetIt.I.registerSingleton<WarehouseRepository>(warehouseRepository);
|
||||
}
|
||||
|
||||
void _stubWarehouseList(_MockWarehouseRepository repository) {
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includeZipcode: any(named: 'includeZipcode'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final page = invocation.namedArguments[const Symbol('page')] as int? ?? 1;
|
||||
final items = page == 1
|
||||
? [Warehouse(id: 1, warehouseCode: 'WH-001', warehouseName: '본사 창고')]
|
||||
: const <Warehouse>[];
|
||||
return PaginatedResult<Warehouse>(
|
||||
items: items,
|
||||
page: page,
|
||||
pageSize:
|
||||
invocation.namedArguments[const Symbol('pageSize')] as int? ?? 20,
|
||||
total: items.length,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
InventorySummaryListResult _buildSummaryResult() {
|
||||
final product = InventoryProduct(
|
||||
id: 10,
|
||||
code: 'INV-10',
|
||||
name: '테스트 장비',
|
||||
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
|
||||
);
|
||||
final warehouse = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
|
||||
final summary = InventorySummary(
|
||||
product: product,
|
||||
totalQuantity: 120,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(warehouse: warehouse, quantity: 80),
|
||||
InventoryWarehouseBalance(
|
||||
warehouse: InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고'),
|
||||
quantity: 40,
|
||||
),
|
||||
],
|
||||
recentEvent: InventoryEvent(
|
||||
eventId: 900,
|
||||
eventKind: 'receipt',
|
||||
eventLabel: '입고',
|
||||
deltaQuantity: 30,
|
||||
occurredAt: DateTime.utc(2025, 1, 3, 9, 0),
|
||||
counterparty: const InventoryCounterparty(
|
||||
type: InventoryCounterpartyType.vendor,
|
||||
name: 'QA 파트너',
|
||||
),
|
||||
warehouse: warehouse,
|
||||
),
|
||||
updatedAt: DateTime.utc(2025, 1, 3, 9, 15),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
|
||||
);
|
||||
return InventorySummaryListResult(
|
||||
result: PaginatedResult<InventorySummary>(
|
||||
items: [summary],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
),
|
||||
lastRefreshedAt: summary.lastRefreshedAt,
|
||||
);
|
||||
}
|
||||
|
||||
InventoryDetail _buildDetail() {
|
||||
final warehouse1 = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
|
||||
final warehouse2 = InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고');
|
||||
return InventoryDetail(
|
||||
product: InventoryProduct(
|
||||
id: 10,
|
||||
code: 'INV-10',
|
||||
name: '테스트 장비',
|
||||
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
|
||||
),
|
||||
totalQuantity: 120,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(warehouse: warehouse1, quantity: 80),
|
||||
InventoryWarehouseBalance(warehouse: warehouse2, quantity: 40),
|
||||
],
|
||||
recentEvents: [
|
||||
InventoryEvent(
|
||||
eventId: 901,
|
||||
eventKind: 'receipt',
|
||||
eventLabel: '입고',
|
||||
deltaQuantity: 20,
|
||||
occurredAt: DateTime.utc(2025, 1, 3, 9, 10),
|
||||
counterparty: const InventoryCounterparty(
|
||||
type: InventoryCounterpartyType.vendor,
|
||||
name: 'QA 파트너',
|
||||
),
|
||||
warehouse: warehouse1,
|
||||
),
|
||||
],
|
||||
updatedAt: DateTime.utc(2025, 1, 3, 9, 20),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(const InventorySummaryFilter());
|
||||
registerFallbackValue(const InventoryDetailFilter());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await GetIt.I.reset();
|
||||
});
|
||||
|
||||
testWidgets('자동 새로고침 토글이 주기적 재조회 동작을 제어한다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
final summaryResult = _buildSummaryResult();
|
||||
final detail = _buildDetail();
|
||||
var listCallCount = 0;
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async {
|
||||
listCallCount += 1;
|
||||
return summaryResult;
|
||||
});
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => detail);
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(listCallCount, 1);
|
||||
expect(find.text('테스트 장비'), findsOneWidget);
|
||||
expect(find.textContaining('마지막 리프레시'), findsOneWidget);
|
||||
expect(find.text('자동 새로고침'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(seconds: 31));
|
||||
await tester.pump();
|
||||
|
||||
expect(listCallCount, 2);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('자동 새로고침 전환'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.pump(const Duration(seconds: 31));
|
||||
await tester.pump();
|
||||
|
||||
expect(listCallCount, 2);
|
||||
});
|
||||
|
||||
testWidgets('행을 탭하면 상세 시트에서 창고 차트와 최근 이벤트를 확인할 수 있다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
final summaryResult = _buildSummaryResult();
|
||||
final detail = _buildDetail();
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => summaryResult);
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => detail);
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('테스트 장비'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('창고 잔량'), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsWidgets);
|
||||
expect(find.text('최근 이벤트'), findsOneWidget);
|
||||
expect(find.textContaining('거래처: QA 파트너'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('권한 오류가 발생하면 경고 배너를 노출한다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenThrow(Exception('재고 조회 권한이 없습니다.'));
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('재고 조회 권한이 없습니다.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('검색 적용 시 입력값이 필터에 반영된다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
final summaryResult = _buildSummaryResult();
|
||||
final detail = _buildDetail();
|
||||
final capturedFilters = <InventorySummaryFilter>[];
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((invocation) async {
|
||||
final filter =
|
||||
invocation.namedArguments[const Symbol('filter')]
|
||||
as InventorySummaryFilter?;
|
||||
if (filter != null) {
|
||||
capturedFilters.add(filter);
|
||||
}
|
||||
return summaryResult;
|
||||
});
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => detail);
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(capturedFilters, isNotEmpty);
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('inventory_filter_query_field')),
|
||||
'카메라',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(const Key('inventory_filter_apply')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(capturedFilters.length, greaterThanOrEqualTo(2));
|
||||
final latest = capturedFilters.last;
|
||||
expect(latest.query, '카메라');
|
||||
expect(latest.page, 1);
|
||||
});
|
||||
|
||||
testWidgets('목록이 비어 있으면 안내 문구를 노출한다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
final emptyResult = InventorySummaryListResult(
|
||||
result: PaginatedResult<InventorySummary>(
|
||||
items: const [],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0,
|
||||
),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 0),
|
||||
);
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => emptyResult);
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => _buildDetail());
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('조건에 맞는 재고 데이터가 없습니다.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('총 수량 헤더를 탭하면 정렬 파라미터가 토글된다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
final summaryResult = _buildSummaryResult();
|
||||
final detail = _buildDetail();
|
||||
final recordedFilters = <InventorySummaryFilter>[];
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((invocation) async {
|
||||
final filter =
|
||||
invocation.namedArguments[const Symbol('filter')]
|
||||
as InventorySummaryFilter?;
|
||||
if (filter != null) {
|
||||
recordedFilters.add(filter);
|
||||
}
|
||||
return summaryResult;
|
||||
});
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => detail);
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(recordedFilters, isNotEmpty);
|
||||
// 첫 정렬: 총 수량 헤더 탭 → 오름차순
|
||||
await tester.tap(find.text('총 수량').first);
|
||||
await tester.pumpAndSettle();
|
||||
final ascFilter = recordedFilters.last;
|
||||
expect(ascFilter.sort, 'total_quantity');
|
||||
expect(ascFilter.order, 'asc');
|
||||
|
||||
// 두 번째 탭 → 내림차순
|
||||
await tester.tap(find.text('총 수량').first);
|
||||
await tester.pumpAndSettle();
|
||||
final descFilter = recordedFilters.last;
|
||||
expect(descFilter.sort, 'total_quantity');
|
||||
expect(descFilter.order, 'desc');
|
||||
});
|
||||
}
|
||||
@@ -14,102 +14,157 @@ class _MockGroupPermissionRepository extends Mock
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('그룹 권한을 모두 불러와 PermissionManager에 적용한다', () async {
|
||||
final repository = _MockGroupPermissionRepository();
|
||||
final manager = PermissionManager();
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: repository,
|
||||
manager: manager,
|
||||
pageSize: 1,
|
||||
);
|
||||
group('PermissionSynchronizer', () {
|
||||
test('그룹 권한을 모두 불러와 PermissionManager에 적용한다', () async {
|
||||
final repository = _MockGroupPermissionRepository();
|
||||
final manager = PermissionManager();
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: repository,
|
||||
manager: manager,
|
||||
pageSize: 1,
|
||||
);
|
||||
|
||||
final permissionPage1 = GroupPermission(
|
||||
id: 1,
|
||||
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 10,
|
||||
menuCode: 'INBOUND',
|
||||
menuName: '입고',
|
||||
path: '/inventory/inbound',
|
||||
),
|
||||
canCreate: true,
|
||||
canRead: true,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
);
|
||||
final permissionPage1 = GroupPermission(
|
||||
id: 1,
|
||||
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 10,
|
||||
menuCode: 'INBOUND',
|
||||
menuName: '입고',
|
||||
path: '/inventory/inbound',
|
||||
),
|
||||
canCreate: true,
|
||||
canRead: true,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
);
|
||||
|
||||
final permissionPage2 = GroupPermission(
|
||||
id: 2,
|
||||
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 11,
|
||||
menuCode: 'OUTBOUND',
|
||||
menuName: '출고',
|
||||
path: '/inventory/outbound',
|
||||
),
|
||||
canCreate: false,
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canDelete: false,
|
||||
);
|
||||
final permissionPage2 = GroupPermission(
|
||||
id: 2,
|
||||
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 11,
|
||||
menuCode: 'OUTBOUND',
|
||||
menuName: '출고',
|
||||
path: '/inventory/outbound',
|
||||
),
|
||||
canCreate: false,
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canDelete: false,
|
||||
);
|
||||
|
||||
when(
|
||||
() => repository.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((invocation) async {
|
||||
final page = invocation.namedArguments[#page] as int;
|
||||
if (page == 1) {
|
||||
when(
|
||||
() => repository.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((invocation) async {
|
||||
final page = invocation.namedArguments[#page] as int;
|
||||
if (page == 1) {
|
||||
return PaginatedResult<GroupPermission>(
|
||||
items: [permissionPage1],
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
total: 2,
|
||||
);
|
||||
}
|
||||
return PaginatedResult<GroupPermission>(
|
||||
items: [permissionPage1],
|
||||
page: 1,
|
||||
items: [permissionPage2],
|
||||
page: 2,
|
||||
pageSize: 1,
|
||||
total: 2,
|
||||
);
|
||||
}
|
||||
return PaginatedResult<GroupPermission>(
|
||||
items: [permissionPage2],
|
||||
page: 2,
|
||||
pageSize: 1,
|
||||
total: 2,
|
||||
});
|
||||
|
||||
await synchronizer.syncForGroup(1);
|
||||
|
||||
verify(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: 1,
|
||||
groupId: 1,
|
||||
menuId: null,
|
||||
isActive: true,
|
||||
includeDeleted: false,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.create,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.edit,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.delete,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
await synchronizer.syncForGroup(1);
|
||||
|
||||
verify(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
test('fetchPermissionMap은 그룹 권한 맵을 반환한다', () async {
|
||||
final repository = _MockGroupPermissionRepository();
|
||||
final manager = PermissionManager();
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: repository,
|
||||
manager: manager,
|
||||
pageSize: 1,
|
||||
groupId: 1,
|
||||
menuId: null,
|
||||
isActive: true,
|
||||
includeDeleted: false,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
);
|
||||
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.create,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(PermissionResources.stockTransactions, PermissionAction.edit),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.delete,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
when(
|
||||
() => repository.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 {
|
||||
return PaginatedResult<GroupPermission>(
|
||||
items: [
|
||||
GroupPermission(
|
||||
id: 1,
|
||||
group: GroupPermissionGroup(id: 99, groupName: 'Ops'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 33,
|
||||
menuCode: 'INVENTORY',
|
||||
menuName: '재고',
|
||||
path: '/inventory/summary',
|
||||
),
|
||||
canRead: true,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
),
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
total: 1,
|
||||
);
|
||||
});
|
||||
|
||||
final map = await synchronizer.fetchPermissionMap(10);
|
||||
|
||||
expect(
|
||||
map[PermissionResources.inventorySummary],
|
||||
contains(PermissionAction.view),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -439,10 +439,7 @@ void main() {
|
||||
expect(rowFinder, findsOneWidget);
|
||||
|
||||
final rowRect = tester.getRect(rowFinder);
|
||||
await tester.tapAt(
|
||||
rowRect.center,
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await tester.tapAt(rowRect.center, kind: PointerDeviceKind.mouse);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SuperportDialog), findsOneWidget);
|
||||
@@ -450,16 +447,14 @@ void main() {
|
||||
await tester.tap(find.text('보안'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final resetButton =
|
||||
find.widgetWithText(ShadButton, '비밀번호 재설정').first;
|
||||
final resetButton = find.widgetWithText(ShadButton, '비밀번호 재설정').first;
|
||||
await tester.ensureVisible(resetButton);
|
||||
await tester.tap(resetButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('비밀번호 재설정'), findsWidgets);
|
||||
|
||||
final confirmButton =
|
||||
find.widgetWithText(ShadButton, '재설정').last;
|
||||
final confirmButton = find.widgetWithText(ShadButton, '재설정').last;
|
||||
await tester.ensureVisible(confirmButton);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Reference in New Issue
Block a user