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:
JiWoong Sul
2025-11-09 01:13:02 +09:00
parent 486ab8706f
commit 47cc62a33d
72 changed files with 5453 additions and 1021 deletions

View File

@@ -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,
);
});
});
}

View File

@@ -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 {

View 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',
}),
);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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),
);
}

View File

@@ -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);
});
});
}

View File

@@ -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

View File

@@ -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'),
);
});
}

View File

@@ -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');
});
}

View File

@@ -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),
);
});
});
}

View File

@@ -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();