diff --git a/.moai/specs/SPEC-RECEIPT-001/acceptance.md b/.moai/specs/SPEC-RECEIPT-001/acceptance.md new file mode 100644 index 0000000..103d886 --- /dev/null +++ b/.moai/specs/SPEC-RECEIPT-001/acceptance.md @@ -0,0 +1,784 @@ +# SPEC-RECEIPT-001 인수 기준 (Acceptance Criteria) + +> **Receipt Upload & Basic Flow - Employee Web App MVP** +> +> Given-When-Then 형식의 상세 검증 시나리오 + +--- + +## 1. 인수 기준 개요 + +### 목적 +이 문서는 SPEC-RECEIPT-001이 완료되었는지 검증하기 위한 구체적인 시나리오를 정의합니다. 모든 시나리오는 Given-When-Then 형식을 따릅니다. + +### 검증 방법 +- **단위 테스트**: Flutter Test로 자동 검증 +- **통합 테스트**: Firebase Emulator 기반 E2E 테스트 +- **수동 테스트**: 실제 브라우저에서 사용자 시나리오 실행 +- **보안 규칙 테스트**: @firebase/rules-unit-testing으로 자동 검증 + +### 통과 기준 +- 모든 자동화 테스트 통과 (100%) +- 수동 테스트 체크리스트 완료 +- SPEC의 모든 EARS 요구사항 충족 + +--- + +## 2. Firebase Storage 업로드 시나리오 + +### AC-1: 유효한 영수증 이미지 업로드 성공 + +**우선순위**: Critical +**테스트 파일**: `tests/services/storage_service_test.dart` + +```gherkin +Given 직원이 Firebase Authentication으로 로그인한 상태이고 + And 유효한 JPG 이미지 파일(2MB, image/jpeg)을 선택했을 때 +When StorageService.uploadReceiptImage(file, userId, receiptId)를 호출하면 +Then Firebase Storage에 파일이 업로드되고 + And 다운로드 URL이 반환되며 + And 반환된 URL은 "gs://receipt-flow-test.appspot.com/receipts/{userId}/{receiptId}/image.jpg" 패턴을 따른다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +test('AC-1: 유효한 영수증 이미지 업로드 성공', () async { + // Given + final userId = 'test-user-123'; + final receiptId = 'receipt-456'; + final mockFile = MockFile( + bytes: Uint8List(2 * 1024 * 1024), // 2MB + mimeType: 'image/jpeg', + ); + + // When + final result = await storageService.uploadReceiptImage( + mockFile, + userId, + receiptId, + ); + + // Then + expect(result.isSuccess, true); + expect(result.data, startsWith('https://firebasestorage.googleapis.com')); + expect(result.data, contains('receipts%2F$userId%2F$receiptId')); +}); +``` + +--- + +### AC-2: 파일 크기 초과 시 업로드 차단 + +**우선순위**: High +**테스트 파일**: `tests/services/storage_service_test.dart` + +```gherkin +Given 직원이 로그인한 상태이고 + And 11MB 크기의 이미지 파일을 선택했을 때 +When uploadReceiptImage를 호출하면 +Then FileTooLargeException이 발생하고 + And 에러 메시지는 "파일 크기는 10MB 이하여야 합니다"를 포함한다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +test('AC-2: 파일 크기 초과 시 업로드 차단', () async { + // Given + final largeFile = MockFile( + bytes: Uint8List(11 * 1024 * 1024), // 11MB + mimeType: 'image/jpeg', + ); + + // When & Then + expect( + () => storageService.uploadReceiptImage(largeFile, userId, receiptId), + throwsA(isA()), + ); +}); +``` + +--- + +### AC-3: 허용되지 않은 파일 형식 거부 + +**우선순위**: High +**테스트 파일**: `tests/services/storage_service_test.dart` + +```gherkin +Given 직원이 로그인한 상태이고 + And .txt 파일(text/plain)을 선택했을 때 +When uploadReceiptImage를 호출하면 +Then InvalidFileTypeException이 발생하고 + And 에러 메시지는 "JPG, PNG, PDF 파일만 업로드 가능합니다"를 포함한다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +test('AC-3: 허용되지 않은 파일 형식 거부', () async { + // Given + final invalidFile = MockFile( + bytes: Uint8List(1024), + mimeType: 'text/plain', // 허용되지 않은 형식 + ); + + // When & Then + expect( + () => storageService.uploadReceiptImage(invalidFile, userId, receiptId), + throwsA(isA()), + ); +}); +``` + +--- + +## 3. Firestore CRUD 시나리오 + +### AC-4: 영수증 생성 성공 (필수 필드 포함) + +**우선순위**: Critical +**테스트 파일**: `tests/services/firestore_service_test.dart` + +```gherkin +Given 직원이 로그인한 상태이고 + And 영수증 이미지가 Firebase Storage에 업로드되어 imageUrl을 획득했으며 + And 필수 정보(날짜: 2025-10-14, 금액: 25000원)를 입력했을 때 +When FirestoreService.createReceipt(receiptData)를 호출하면 +Then Firestore receipts 컬렉션에 새 문서가 생성되고 + And 문서 ID가 반환되며 + And createdAt 필드는 서버 타임스탬프로 자동 설정되고 + And isSubmitted 필드는 true로 설정된다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +test('AC-4: 영수증 생성 성공', () async { + // Given + final receiptData = { + 'userId': testUserId, + 'imageUrl': 'https://storage.googleapis.com/test/image.jpg', + 'amount': 25000.0, + 'date': Timestamp.fromDate(DateTime(2025, 10, 14)), + 'category': '식비', + 'businessPurpose': '고객 미팅', + }; + + // When + final receiptId = await firestoreService.createReceipt(receiptData); + + // Then + expect(receiptId, isNotEmpty); + + final snapshot = await FirebaseFirestore.instance + .collection('receipts') + .doc(receiptId) + .get(); + + expect(snapshot.exists, true); + expect(snapshot.data()!['userId'], testUserId); + expect(snapshot.data()!['amount'], 25000.0); + expect(snapshot.data()!['isSubmitted'], true); + expect(snapshot.data()!['createdAt'], isA()); +}); +``` + +--- + +### AC-5: 필수 필드 누락 시 생성 차단 + +**우선순위**: High +**테스트 파일**: `tests/services/firestore_service_test.dart` + +```gherkin +Given 직원이 로그인한 상태이고 + And 영수증 데이터에서 amount 필드가 누락되었을 때 +When createReceipt를 호출하면 +Then MissingRequiredFieldException이 발생하고 + And 에러 메시지는 "필수 필드(날짜, 금액)가 누락되었습니다"를 포함한다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +test('AC-5: 필수 필드 누락 시 생성 차단', () async { + // Given + final invalidData = { + 'userId': testUserId, + 'imageUrl': 'https://storage.googleapis.com/test/image.jpg', + 'date': Timestamp.now(), + // amount 필드 누락 + }; + + // When & Then + expect( + () => firestoreService.createReceipt(invalidData), + throwsA(isA()), + ); +}); +``` + +--- + +### AC-6: 본인 영수증만 조회 가능 (필터링) + +**우선순위**: Critical +**테스트 파일**: `tests/services/firestore_service_test.dart` + +```gherkin +Given User A와 User B가 각각 영수증을 제출한 상태이고 + And User A로 로그인했을 때 +When FirestoreService.getReceipts(userA.uid)를 호출하면 +Then User A의 영수증만 반환되고 + And User B의 영수증은 포함되지 않는다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +test('AC-6: 본인 영수증만 조회 가능', () async { + // Given + final userA = 'user-a-123'; + final userB = 'user-b-456'; + + await firestoreService.createReceipt({ + 'userId': userA, + 'imageUrl': 'https://test.com/a.jpg', + 'amount': 10000, + 'date': Timestamp.now(), + }); + + await firestoreService.createReceipt({ + 'userId': userB, + 'imageUrl': 'https://test.com/b.jpg', + 'amount': 20000, + 'date': Timestamp.now(), + }); + + // When + final receiptsA = await firestoreService.getReceipts(userA).first; + + // Then + expect(receiptsA.length, 1); + expect(receiptsA.every((r) => r.userId == userA), true); +}); +``` + +--- + +### AC-7: 제출된 영수증 수정 불가 + +**우선순위**: High +**테스트 파일**: `tests/services/firestore_service_test.dart` + +```gherkin +Given 직원이 영수증을 제출하여 isSubmitted = true 상태이고 +When 해당 영수증의 amount를 수정하려고 시도하면 +Then PermissionDeniedException이 발생하거나 + And Firestore 보안 규칙이 요청을 거부한다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +test('AC-7: 제출된 영수증 수정 불가', () async { + // Given + final receiptId = await firestoreService.createReceipt({ + 'userId': testUserId, + 'imageUrl': 'https://test.com/image.jpg', + 'amount': 10000, + 'date': Timestamp.now(), + 'isSubmitted': true, + }); + + // When & Then + expect( + () => firestoreService.updateReceipt(receiptId, {'amount': 20000}), + throwsA(isA()), + ); +}); +``` + +--- + +## 4. UI 동작 시나리오 (Widget Tests) + +### AC-8: 파일 선택 버튼 동작 + +**우선순위**: High +**테스트 파일**: `tests/pages/receipt_upload_page_test.dart` + +```gherkin +Given ReceiptUploadPage가 렌더링된 상태이고 +When "파일 선택" 버튼을 클릭하면 +Then FilePicker.platform.pickFiles()가 호출되고 + And 파일 선택 다이얼로그가 열린다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +testWidgets('AC-8: 파일 선택 버튼 동작', (tester) async { + // Given + await tester.pumpWidget(MaterialApp(home: ReceiptUploadPage())); + + // When + final filePickerButton = find.text('파일 선택'); + await tester.tap(filePickerButton); + await tester.pumpAndSettle(); + + // Then + verify(mockFilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'png', 'pdf'], + )).called(1); +}); +``` + +--- + +### AC-9: 필수 필드 누락 시 제출 버튼 비활성화 + +**우선순위**: High +**테스트 파일**: `tests/pages/receipt_upload_page_test.dart` + +```gherkin +Given ReceiptUploadPage에서 파일은 선택했지만 + And amount 필드가 비어있을 때 +When 제출 버튼 상태를 확인하면 +Then 제출 버튼이 비활성화(disabled) 상태이고 + And 클릭해도 제출되지 않는다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +testWidgets('AC-9: 필수 필드 누락 시 제출 버튼 비활성화', (tester) async { + // Given + await tester.pumpWidget(MaterialApp(home: ReceiptUploadPage())); + + // 파일 선택 (imageUrl 설정됨) + // amount 필드는 비워둠 + + // When + final submitButton = find.widgetWithText(ElevatedButton, '제출'); + + // Then + final button = tester.widget(submitButton); + expect(button.enabled, false); +}); +``` + +--- + +### AC-10: 업로드 진행률 표시 + +**우선순위**: Medium +**테스트 파일**: `tests/pages/receipt_upload_page_test.dart` + +```gherkin +Given 직원이 3MB 이미지 파일을 선택하고 +When 업로드를 시작하면 +Then CircularProgressIndicator가 표시되고 + And 진행률(0% → 100%)이 업데이트되며 + And 완료 시 미리보기 이미지가 표시된다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +testWidgets('AC-10: 업로드 진행률 표시', (tester) async { + // Given + await tester.pumpWidget(MaterialApp(home: ReceiptUploadPage())); + final mockFile = MockFile(bytes: Uint8List(3 * 1024 * 1024)); + + // When + await tester.tap(find.text('파일 선택')); + await tester.pumpAndSettle(); + + // Then + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // 업로드 완료 후 + await tester.pumpAndSettle(Duration(seconds: 5)); + expect(find.byType(CachedNetworkImage), findsOneWidget); +}); +``` + +--- + +## 5. 실시간 목록 조회 시나리오 (StreamBuilder) + +### AC-11: Firestore 변경 시 실시간 UI 업데이트 + +**우선순위**: Critical +**테스트 파일**: `tests/pages/receipt_list_page_test.dart` + +```gherkin +Given ReceiptListPage가 렌더링된 상태이고 + And 초기 영수증 목록이 2개 표시되고 있을 때 +When 다른 세션에서 새 영수증을 추가하면 +Then StreamBuilder가 자동으로 새 데이터를 수신하고 + And 목록이 3개로 업데이트되며 + And 화면을 새로고침하지 않아도 반영된다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +testWidgets('AC-11: Firestore 변경 시 실시간 UI 업데이트', (tester) async { + // Given + await tester.pumpWidget(MaterialApp(home: ReceiptListPage())); + await tester.pumpAndSettle(); + + expect(find.byType(ReceiptCard), findsNWidgets(2)); + + // When (다른 세션에서 추가) + await firestoreService.createReceipt({ + 'userId': testUserId, + 'imageUrl': 'https://test.com/new.jpg', + 'amount': 30000, + 'date': Timestamp.now(), + }); + + await tester.pump(); // StreamBuilder 업데이트 대기 + + // Then + expect(find.byType(ReceiptCard), findsNWidgets(3)); +}); +``` + +--- + +### AC-12: 날짜 역순 정렬 + +**우선순위**: Medium +**테스트 파일**: `tests/services/firestore_service_test.dart` + +```gherkin +Given 직원이 영수증 3개를 서로 다른 날짜에 제출했고 + And 제출 순서가 2025-10-10, 2025-10-12, 2025-10-14일 때 +When ReceiptListPage에서 목록을 조회하면 +Then 최신 영수증(2025-10-14)이 맨 위에 표시되고 + And 오래된 영수증(2025-10-10)이 맨 아래에 표시된다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +test('AC-12: 날짜 역순 정렬', () async { + // Given + await firestoreService.createReceipt({ + 'userId': testUserId, + 'date': Timestamp.fromDate(DateTime(2025, 10, 10)), + 'amount': 10000, + 'imageUrl': 'https://test.com/1.jpg', + }); + + await firestoreService.createReceipt({ + 'userId': testUserId, + 'date': Timestamp.fromDate(DateTime(2025, 10, 14)), + 'amount': 30000, + 'imageUrl': 'https://test.com/3.jpg', + }); + + // When + final receipts = await firestoreService + .getReceipts(testUserId) + .first; + + // Then + expect(receipts[0].date, DateTime(2025, 10, 14)); // 최신 + expect(receipts[1].date, DateTime(2025, 10, 10)); // 오래됨 +}); +``` + +--- + +## 6. 보안 규칙 시나리오 + +### AC-13: 인증되지 않은 사용자 접근 차단 + +**우선순위**: Critical +**테스트 파일**: `tests/security/firestore_rules_test.dart` + +```gherkin +Given 사용자가 로그아웃한 상태(request.auth == null)이고 +When Firestore.collection('receipts').get()을 호출하면 +Then permission-denied 에러가 발생하고 + And 데이터에 접근할 수 없다 +``` + +**검증 코드**: +```javascript +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +// Node.js 테스트 (@firebase/rules-unit-testing) +test('AC-13: 인증되지 않은 사용자 접근 차단', async () => { + const db = testEnv.unauthenticatedContext().firestore(); + + await assertFails( + db.collection('receipts').get() + ); +}); +``` + +--- + +### AC-14: 다른 사용자 영수증 접근 차단 + +**우선순위**: Critical +**테스트 파일**: `tests/security/firestore_rules_test.dart` + +```gherkin +Given User A가 로그인한 상태이고 + And User B의 영수증 문서 ID를 알고 있을 때 +When User A가 User B의 영수증을 조회하려고 시도하면 +Then permission-denied 에러가 발생하고 + And Firestore 보안 규칙이 요청을 거부한다 +``` + +**검증 코드**: +```javascript +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +test('AC-14: 다른 사용자 영수증 접근 차단', async () => { + const userADb = testEnv.authenticatedContext('user-a').firestore(); + + // User B의 영수증 미리 생성 + const userBDb = testEnv.authenticatedContext('user-b').firestore(); + const receiptRef = await userBDb.collection('receipts').add({ + userId: 'user-b', + amount: 10000, + imageUrl: 'https://test.com/b.jpg', + date: firestore.Timestamp.now(), + }); + + // User A가 User B 영수증 조회 시도 + await assertFails( + userADb.collection('receipts').doc(receiptRef.id).get() + ); +}); +``` + +--- + +### AC-15: Storage 보안 규칙 - 파일 크기 제한 + +**우선순위**: High +**테스트 파일**: `tests/security/storage_rules_test.dart` + +```gherkin +Given 직원이 로그인한 상태이고 +When 11MB 크기의 파일을 Firebase Storage에 업로드하려고 시도하면 +Then Storage 보안 규칙이 요청을 거부하고 + And permission-denied 에러가 발생한다 +``` + +**검증 코드**: +```javascript +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +test('AC-15: Storage 파일 크기 제한', async () => { + const storage = testEnv.authenticatedContext('user-a').storage(); + const largeFile = Buffer.alloc(11 * 1024 * 1024); // 11MB + + await assertFails( + storage.ref('receipts/user-a/receipt-123/image.jpg').put(largeFile) + ); +}); +``` + +--- + +## 7. Record/Snapshot 패턴 검증 + +### AC-16: DocumentSnapshot → ReceiptRecord 변환 정확성 + +**우선순위**: High +**테스트 파일**: `tests/models/receipt_record_test.dart` + +```gherkin +Given Firestore DocumentSnapshot이 다음 데이터를 포함하고 + { + "userId": "user-123", + "amount": 25000.5, + "date": Timestamp(2025-10-14), + "imageUrl": "https://test.com/image.jpg", + "createdAt": Timestamp(2025-10-14 10:30:00), + "isSubmitted": true + } +When ReceiptRecord.fromSnapshot(snapshot)을 호출하면 +Then 모든 필드가 정확히 타입 변환되고 + And amount는 double 타입이며 + And date와 createdAt는 DateTime 타입이고 + And id는 snapshot.id와 일치한다 +``` + +**검증 코드**: +```dart +// @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md +test('AC-16: DocumentSnapshot → ReceiptRecord 변환', () { + // Given + final mockSnapshot = MockDocumentSnapshot( + id: 'receipt-123', + data: { + 'userId': 'user-123', + 'amount': 25000.5, + 'date': Timestamp.fromDate(DateTime(2025, 10, 14)), + 'imageUrl': 'https://test.com/image.jpg', + 'createdAt': Timestamp.fromDate(DateTime(2025, 10, 14, 10, 30)), + 'isSubmitted': true, + }, + ); + + // When + final record = ReceiptRecord.fromSnapshot(mockSnapshot); + + // Then + expect(record.id, 'receipt-123'); + expect(record.userId, 'user-123'); + expect(record.amount, 25000.5); + expect(record.amount, isA()); + expect(record.date, DateTime(2025, 10, 14)); + expect(record.createdAt, DateTime(2025, 10, 14, 10, 30)); + expect(record.isSubmitted, true); +}); +``` + +--- + +## 8. 수동 테스트 체크리스트 + +### 브라우저 호환성 테스트 + +**테스트 환경**: Chrome, Safari, Edge + +- [ ] **Chrome 최신 버전**: + - [ ] 로그인 성공 + - [ ] 파일 선택 다이얼로그 정상 동작 + - [ ] 업로드 진행률 표시 + - [ ] 목록 페이지 실시간 업데이트 + +- [ ] **Safari 최신 버전**: + - [ ] 파일 업로드 동작 + - [ ] UI 레이아웃 정상 표시 + - [ ] shadcn_flutter 컴포넌트 렌더링 + +- [ ] **Edge 최신 버전**: + - [ ] 전체 플로우 정상 동작 + +--- + +### End-to-End 플로우 테스트 + +**시나리오**: 실제 사용자 워크플로우 + +1. **로그인**: + - [ ] 이메일/비밀번호 입력 + - [ ] 로그인 성공 시 ReceiptListPage 이동 + +2. **영수증 업로드**: + - [ ] "새 영수증" 버튼 클릭 + - [ ] 파일 선택 (JPG 2MB) + - [ ] 날짜 선택 (DatePicker) + - [ ] 금액 입력 (25000원) + - [ ] 카테고리 선택 (드롭다운) + - [ ] 비즈니스 목적 입력 + - [ ] 제출 버튼 클릭 + - [ ] 성공 메시지 확인 + +3. **목록 조회**: + - [ ] 방금 추가한 영수증이 맨 위에 표시 + - [ ] 이미지 썸네일 로딩 + - [ ] 금액/날짜 포맷팅 확인 + +4. **실시간 동기화**: + - [ ] 다른 브라우저 탭에서 새 영수증 추가 + - [ ] 원래 탭에서 자동 업데이트 확인 + +5. **로그아웃**: + - [ ] 로그아웃 버튼 클릭 + - [ ] 로그인 페이지로 이동 + +--- + +### 에러 처리 테스트 + +**예외 상황 시나리오**: + +- [ ] **네트워크 오류**: + - [ ] Wi-Fi 끊기 → 업로드 실패 메시지 확인 + - [ ] 재시도 로직 동작 확인 + +- [ ] **파일 크기 초과**: + - [ ] 11MB 파일 선택 → 에러 메시지 표시 + - [ ] "파일 크기는 10MB 이하여야 합니다" 확인 + +- [ ] **잘못된 파일 형식**: + - [ ] .txt 파일 선택 → 에러 메시지 + - [ ] "JPG, PNG, PDF 파일만 업로드 가능합니다" 확인 + +- [ ] **필수 필드 누락**: + - [ ] 금액 입력 안 함 → 제출 버튼 비활성화 + - [ ] 활성화 조건 확인 + +--- + +## 9. Definition of Done (DoD) + +### 자동화 테스트 +- [ ] 모든 단위 테스트 통과 (16개 시나리오) +- [ ] 위젯 테스트 통과 (3개 시나리오) +- [ ] 보안 규칙 테스트 통과 (3개 시나리오) +- [ ] 통합 테스트 E2E 플로우 통과 (1개 시나리오) + +### 수동 테스트 +- [ ] 브라우저 호환성 체크리스트 완료 +- [ ] End-to-End 플로우 체크리스트 완료 +- [ ] 에러 처리 시나리오 검증 완료 + +### SPEC 준수 +- [ ] EARS 요구사항 모두 구현 확인 +- [ ] TAG 체인 무결성 검증 +- [ ] TRUST 5원칙 준수 확인 + +### 문서화 +- [ ] API 문서 생성 (dart doc) +- [ ] README.md 사용법 가이드 추가 +- [ ] deployment.md 작성 + +--- + +## 10. 추적성 매트릭스 + +| AC ID | SPEC 요구사항 | 테스트 파일 | 구현 파일 | 상태 | +|-------|--------------|------------|----------|------| +| AC-1 | Event-driven: 파일 업로드 | storage_service_test.dart | storage_service.dart | ⏳ Pending | +| AC-2 | Constraints: 파일 크기 제한 | storage_service_test.dart | storage_service.dart | ⏳ Pending | +| AC-3 | Constraints: 파일 형식 검증 | storage_service_test.dart | storage_service.dart | ⏳ Pending | +| AC-4 | Event-driven: 영수증 생성 | firestore_service_test.dart | firestore_service.dart | ⏳ Pending | +| AC-5 | Constraints: 필수 필드 검증 | firestore_service_test.dart | firestore_service.dart | ⏳ Pending | +| AC-6 | Ubiquitous: 본인 영수증 조회 | firestore_service_test.dart | firestore_service.dart | ⏳ Pending | +| AC-7 | State-driven: 제출된 영수증 수정 불가 | firestore_service_test.dart | firestore_service.dart | ⏳ Pending | +| AC-8 | UI: 파일 선택 버튼 | receipt_upload_page_test.dart | receipt_upload_page.dart | ⏳ Pending | +| AC-9 | UI: 제출 버튼 비활성화 | receipt_upload_page_test.dart | receipt_upload_page.dart | ⏳ Pending | +| AC-10 | Event-driven: 업로드 진행률 | receipt_upload_page_test.dart | receipt_upload_page.dart | ⏳ Pending | +| AC-11 | Event-driven: 실시간 UI 업데이트 | receipt_list_page_test.dart | receipt_list_page.dart | ⏳ Pending | +| AC-12 | Ubiquitous: 날짜 역순 정렬 | firestore_service_test.dart | firestore_service.dart | ⏳ Pending | +| AC-13 | Security: 인증되지 않은 접근 차단 | firestore_rules_test.js | firestore.rules | ⏳ Pending | +| AC-14 | Security: 다른 사용자 영수증 차단 | firestore_rules_test.js | firestore.rules | ⏳ Pending | +| AC-15 | Security: Storage 파일 크기 제한 | storage_rules_test.js | storage.rules | ⏳ Pending | +| AC-16 | Architecture: Record/Snapshot 패턴 | receipt_record_test.dart | receipt_record.dart | ⏳ Pending | + +**진행 상태 범례**: +- ⏳ Pending: 구현 대기 +- 🔴 RED: 테스트 작성 완료, 실패 확인 +- 🟢 GREEN: 구현 완료, 테스트 통과 +- ♻️ REFACTOR: 리팩토링 완료 + +--- + +**작성일**: 2025-10-14 +**작성자**: @edward (spec-builder 에이전트) +**관련 SPEC**: SPEC-RECEIPT-001.md +**관련 계획**: plan.md diff --git a/.moai/specs/SPEC-RECEIPT-001/plan.md b/.moai/specs/SPEC-RECEIPT-001/plan.md new file mode 100644 index 0000000..f1e3088 --- /dev/null +++ b/.moai/specs/SPEC-RECEIPT-001/plan.md @@ -0,0 +1,464 @@ +# SPEC-RECEIPT-001 구현 계획서 (Implementation Plan) + +> **Receipt Upload & Basic Flow - Employee Web App MVP** +> +> Flutter Web + Firebase 기반 영수증 관리 시스템 TDD 구현 계획 + +--- + +## 1. Overview (개요) + +### 목표 +Flutter Web 환경에서 Firebase를 백엔드로 활용하여 직원이 영수증을 업로드하고 관리할 수 있는 MVP를 구현합니다. + +### 핵심 가치 +- **SPEC-First TDD**: spec.md 요구사항 기반 테스트 작성 +- **FlutterFlow 패턴**: Record/Snapshot 타입 안전 패턴 적용 +- **Firebase Emulator**: 로컬 개발 환경 구축 +- **Real-time UI**: StreamBuilder 기반 실시간 데이터 동기화 + +### 기술 스택 확정 +- **Flutter**: 3.24.5 (최신 안정 버전) +- **Firebase Core**: ^3.9.0 +- **Cloud Firestore**: ^5.6.0 +- **Firebase Auth**: ^5.3.3 +- **Firebase Storage**: ^12.3.8 +- **shadcn_flutter**: ^0.6.0 (UI 컴포넌트) +- **file_picker**: ^8.1.6 (Web 파일 선택) + +--- + +## 2. TDD Implementation Plan (RED-GREEN-REFACTOR) + +### 2.1. RED Phase: 실패하는 테스트 작성 + +**원칙**: +- SPEC 요구사항을 테스트 케이스로 변환 +- Firebase Emulator 환경에서 테스트 실행 +- 각 테스트는 실패 확인 후 다음 단계 진행 + +**테스트 작성 순서**: + +1. **모델 테스트** (`tests/models/receipt_record_test.dart`): + ```dart + // @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md + test('ReceiptRecord.fromSnapshot은 Firestore DocumentSnapshot을 올바르게 파싱해야 한다', () { + // Given: Mock DocumentSnapshot + // When: ReceiptRecord.fromSnapshot(snapshot) + // Then: 모든 필드가 정확히 매핑됨 + }); + + test('ReceiptRecord.toMap은 Firestore 저장 형식으로 변환해야 한다', () { + // Given: ReceiptRecord 객체 + // When: record.toMap() + // Then: Map 반환, Timestamp 타입 확인 + }); + ``` + +2. **Storage Service 테스트** (`tests/services/storage_service_test.dart`): + ```dart + // @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md + test('파일 업로드 시 10MB 초과하면 예외를 던져야 한다', () async { + // Given: 11MB 크기 파일 + // When: uploadReceiptImage(largeFile) + // Then: FileTooLargeException 발생 + }); + + test('허용되지 않은 파일 형식은 거부해야 한다', () async { + // Given: .txt 파일 + // When: uploadReceiptImage(invalidFile) + // Then: InvalidFileTypeException 발생 + }); + + test('정상 업로드 시 다운로드 URL을 반환해야 한다', () async { + // Given: 유효한 JPG 파일 + // When: uploadReceiptImage(validFile) + // Then: Firebase Storage URL 반환 + }); + ``` + +3. **Firestore Service 테스트** (`tests/services/firestore_service_test.dart`): + ```dart + // @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md + test('영수증 생성 시 필수 필드가 누락되면 예외를 던져야 한다', () async { + // Given: amount 필드 누락 + // When: createReceipt(invalidData) + // Then: MissingRequiredFieldException 발생 + }); + + test('본인 영수증만 조회할 수 있어야 한다', () async { + // Given: User A로 로그인 + // When: getReceipts(userA.uid) + // Then: User A의 영수증만 반환 + }); + + test('제출된 영수증은 수정할 수 없어야 한다', () async { + // Given: isSubmitted = true인 영수증 + // When: updateReceipt(submittedReceipt) + // Then: PermissionDeniedException 발생 + }); + ``` + +4. **보안 규칙 테스트** (`tests/security/firestore_rules_test.dart`): + ```dart + // @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md + test('인증되지 않은 사용자는 영수증을 읽을 수 없어야 한다', () async { + // Given: 로그아웃 상태 + // When: Firestore.collection('receipts').get() + // Then: permission-denied 에러 + }); + + test('다른 사용자의 영수증을 읽으려 하면 거부해야 한다', () async { + // Given: User A로 로그인 + // When: User B의 영수증 조회 + // Then: permission-denied 에러 + }); + ``` + +5. **위젯 통합 테스트** (`tests/pages/receipt_upload_page_test.dart`): + ```dart + // @TEST:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md + testWidgets('파일 선택 버튼을 클릭하면 파일 선택 다이얼로그가 열려야 한다', (tester) async { + // Given: ReceiptUploadPage 렌더링 + // When: 파일 선택 버튼 탭 + // Then: FilePicker 호출 확인 + }); + + testWidgets('필수 필드 누락 시 제출 버튼이 비활성화되어야 한다', (tester) async { + // Given: amount 필드 비어있음 + // When: 제출 버튼 확인 + // Then: 버튼 disabled 상태 + }); + ``` + +### 2.2. GREEN Phase: 최소 구현 + +**원칙**: +- 테스트를 통과하는 최소한의 코드만 작성 +- 중복 제거나 최적화는 REFACTOR 단계에서 수행 +- SPEC 요구사항을 충족하는지 확인 + +**구현 순서**: + +1. **Firebase 초기화** (`lib/main.dart`): + ```dart + // @CODE:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md + void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + runApp(MyApp()); + } + ``` + +2. **ReceiptRecord 모델** (`lib/models/receipt_record.dart`): + - FlutterFlow 스타일 fromSnapshot, toMap 구현 + - Timestamp ↔ DateTime 변환 로직 + - Null safety 처리 + +3. **StorageService** (`lib/services/storage_service.dart`): + - 파일 크기 검증 (10MB) + - MIME 타입 검증 (image/jpeg, image/png, application/pdf) + - Firebase Storage 업로드 (putData) + - UploadTask 진행률 스트림 반환 + +4. **FirestoreService** (`lib/services/firestore_service.dart`): + - CRUD 메서드 구현 (createReceipt, getReceipts, updateReceipt) + - userId 필터링 + - FieldValue.serverTimestamp() 사용 + - Stream> 반환 + +5. **ReceiptUploadPage** (`lib/pages/receipts/receipt_upload_page.dart`): + - shadcn_flutter Input, Button, DatePicker 컴포넌트 사용 + - StatefulWidget 상태 관리 + - 파일 선택 → 업로드 → Firestore 저장 플로우 + - 에러 처리 및 로딩 상태 표시 + +6. **ReceiptListPage** (`lib/pages/receipts/receipt_list_page.dart`): + - StreamBuilder> 사용 + - ListView.builder 렌더링 + - shadcn_flutter Card 컴포넌트 + +### 2.3. REFACTOR Phase: 코드 품질 개선 + +**원칙**: +- SPEC 요구사항 충족 상태 유지 +- 테스트 통과 상태 유지 +- TRUST 5원칙 적용 + +**리팩토링 항목**: + +1. **타입 안전성 강화**: + - Result 타입 도입 (Success/Failure) + - 예외 대신 타입 안전한 에러 처리 + - Freezed 패키지 고려 (불변 데이터 클래스) + +2. **코드 중복 제거**: + - 공통 위젯 추출 (LoadingIndicator, ErrorMessage) + - 공통 검증 로직 추출 (Validator 클래스) + +3. **성능 최적화**: + - StreamBuilder 불필요한 재빌드 방지 + - CachedNetworkImage로 이미지 캐싱 + - Firestore 쿼리 인덱스 최적화 + +4. **보안 강화**: + - 클라이언트 측 입력 검증 추가 + - Firebase 보안 규칙 단위 테스트 추가 + - 민감 정보 로깅 제거 + +5. **TDD 이력 주석 추가**: + ```dart + // @CODE:RECEIPT-001 | SPEC: SPEC-RECEIPT-001.md + // TDD History: + // - RED: tests/services/firestore_service_test.dart (2025-10-14) + // - GREEN: CRUD 메서드 최소 구현 (2025-10-14) + // - REFACTOR: Result 타입 도입, 에러 핸들링 개선 (2025-10-15) + class FirestoreService { + // ... + } + ``` + +--- + +## 3. Module Implementation Order (모듈 구현 순서) + +### Priority 1: 인프라 계층 (Infrastructure Layer) +**목표**: Firebase 연결 및 기본 서비스 구축 + +1. **Firebase 초기화**: + - FlutterFire CLI로 `firebase_options.dart` 생성 + - Firebase Emulator 설정 (firestore.rules, storage.rules) + - main.dart에서 Firebase 초기화 + +2. **AuthService** (기본 인증): + - 이메일/비밀번호 로그인 + - 현재 사용자 UID 조회 + - 로그아웃 + +**완료 기준**: Firebase Emulator에 연결 성공, 인증 플로우 동작 + +### Priority 2: 데이터 계층 (Data Layer) +**목표**: 타입 안전한 데이터 모델 및 서비스 + +1. **ReceiptRecord 모델**: + - fromSnapshot 구현 + - toMap 구현 + - 단위 테스트 통과 + +2. **FirestoreService**: + - createReceipt (필수 필드 검증 포함) + - getReceipts (userId 필터링) + - updateReceipt (isSubmitted 확인) + - 단위 테스트 통과 + +3. **StorageService**: + - uploadReceiptImage (크기/형식 검증) + - getDownloadURL + - 단위 테스트 통과 + +**완료 기준**: 모든 서비스 테스트 통과, Firebase Emulator에서 CRUD 동작 확인 + +### Priority 3: UI 계층 (Presentation Layer) +**목표**: shadcn_flutter 기반 사용자 인터페이스 + +1. **ReceiptUploadPage**: + - 파일 선택 (file_picker) + - 업로드 진행률 표시 + - 폼 입력 (날짜, 금액, 카테고리, 비즈니스 목적) + - 제출 버튼 (필수 필드 검증) + +2. **ReceiptListPage**: + - StreamBuilder로 실시간 목록 표시 + - 영수증 카드 컴포넌트 + - 날짜/금액 포맷팅 + +3. **공통 위젯**: + - ReceiptCard (shadcn_flutter Card 활용) + - FilePickerButton (shadcn_flutter Button) + - LoadingOverlay + +**완료 기준**: 위젯 테스트 통과, 실제 UI 동작 확인 + +### Priority 4: 보안 및 검증 (Security & Validation) +**목표**: Firebase 보안 규칙 및 입력 검증 + +1. **Firestore 보안 규칙**: + - 본인 영수증만 읽기/쓰기 + - 필수 필드 검증 (amount > 0, imageUrl 존재) + - isSubmitted == false일 때만 수정 허용 + +2. **Storage 보안 규칙**: + - 본인만 업로드/다운로드 + - 파일 크기 10MB 제한 + - MIME 타입 검증 + +3. **보안 규칙 테스트**: + - @firebase/rules-unit-testing 사용 + - 권한 거부 시나리오 테스트 + +**완료 기준**: 보안 규칙 테스트 통과, 권한 위반 시 접근 거부 확인 + +--- + +## 4. Testing Strategy (테스트 전략) + +### 4.1. 단위 테스트 (Unit Tests) +**도구**: Flutter Test + Mockito + +- **모델 테스트**: ReceiptRecord fromSnapshot/toMap +- **서비스 테스트**: FirestoreService, StorageService (Firebase Emulator) +- **검증 로직 테스트**: Validator 클래스 + +**커버리지 목표**: 85% 이상 + +### 4.2. 위젯 테스트 (Widget Tests) +**도구**: Flutter Widget Testing + +- **페이지 테스트**: ReceiptUploadPage, ReceiptListPage +- **컴포넌트 테스트**: ReceiptCard, FilePickerButton +- **상태 변화 테스트**: 로딩, 에러, 성공 상태 + +**커버리지 목표**: 주요 위젯 80% 이상 + +### 4.3. 통합 테스트 (Integration Tests) +**도구**: Firebase Emulator + Flutter Integration Test + +- **End-to-End 플로우**: + 1. 로그인 + 2. 영수증 업로드 + 3. 목록 조회 + 4. 로그아웃 + +**시나리오**: 최소 3개 (정상 플로우, 에러 플로우, 보안 플로우) + +### 4.4. 보안 규칙 테스트 +**도구**: @firebase/rules-unit-testing (Node.js) + +- 인증되지 않은 접근 거부 +- 다른 사용자 영수증 접근 거부 +- 필수 필드 누락 시 생성 거부 +- 제출된 영수증 수정 거부 + +**자동화**: `npm run test:rules` 스크립트 + +--- + +## 5. Risk Mitigation (리스크 대응) + +### 기술적 리스크 + +| 리스크 | 영향도 | 대응 방안 | +|------------------------------|--------|----------------------------------------| +| Firebase Emulator 연결 실패 | High | Emulator 설정 문서 참조, 포트 확인 | +| Flutter Web 파일 업로드 제약 | Medium | file_picker 라이브러리 사용, CORS 설정 | +| Firestore 보안 규칙 복잡도 | Medium | 단순화, 단위 테스트 강화 | +| shadcn_flutter 호환성 이슈 | Low | 대체 UI 라이브러리 준비 (Material 3) | + +### 일정 리스크 + +| 리스크 | 대응 방안 | +|-------------------------|-----------------------------------| +| Firebase 설정 지연 | 1일차 우선 완료, 문서화 | +| 보안 규칙 테스트 복잡도 | 간단한 시나리오부터 점진적 확장 | +| UI 디자인 반복 작업 | Storybook 먼저 구축, 피드백 반영 | + +--- + +## 6. Milestones (마일스톤) + +### Milestone 1: 인프라 구축 ✅ +**목표**: Firebase 연결 및 인증 완료 + +- Firebase Emulator 설정 +- AuthService 구현 +- 로그인 페이지 동작 확인 + +**검증**: Emulator UI에서 사용자 생성 확인 + +--- + +### Milestone 2: 데이터 계층 완성 ✅ +**목표**: CRUD 기능 완료 + +- ReceiptRecord 모델 +- FirestoreService 테스트 통과 +- StorageService 테스트 통과 + +**검증**: `flutter test` 모두 통과 + +--- + +### Milestone 3: UI 구현 ✅ +**목표**: 영수증 업로드 및 목록 페이지 완성 + +- ReceiptUploadPage 동작 +- ReceiptListPage 실시간 동기화 확인 +- shadcn_flutter 컴포넌트 통합 + +**검증**: 실제 브라우저에서 E2E 플로우 완료 + +--- + +### Milestone 4: 보안 강화 ✅ +**목표**: 보안 규칙 테스트 통과 + +- Firestore/Storage 보안 규칙 작성 +- 권한 테스트 통과 +- 클라이언트 검증 추가 + +**검증**: `npm run test:rules` 통과 + +--- + +### Milestone 5: 문서화 및 배포 준비 ✅ +**목표**: `/alfred:3-sync` 실행 준비 + +- Living Document 동기화 +- README.md 업데이트 +- deployment.md 작성 (Firebase Hosting) + +**검증**: PR Ready 상태, CI/CD 통과 + +--- + +## 7. Definition of Done (완료 기준) + +### 코드 품질 +- [ ] 모든 단위 테스트 통과 (커버리지 ≥ 85%) +- [ ] 위젯 테스트 통과 (주요 컴포넌트 ≥ 80%) +- [ ] Firebase 보안 규칙 테스트 통과 +- [ ] 린터 에러 0건 (`flutter analyze`) +- [ ] 포맷팅 준수 (`dart format`) + +### SPEC 준수 +- [ ] EARS 요구사항 모두 구현 확인 +- [ ] TAG 체인 무결성 검증 (`rg '@(SPEC|TEST|CODE):RECEIPT-001'`) +- [ ] acceptance.md의 모든 시나리오 통과 + +### 문서화 +- [ ] @CODE TAG 주석 추가 +- [ ] TDD History 주석 작성 +- [ ] API 문서 생성 (dart doc) + +### Git 작업 +- [ ] feature/SPEC-RECEIPT-001 브랜치 생성 +- [ ] RED-GREEN-REFACTOR 단계별 커밋 +- [ ] Draft PR 생성 (develop ← feature) + +--- + +## 8. Next Steps (다음 단계) + +1. **즉시 실행**: `/alfred:2-build SPEC-RECEIPT-001` +2. **TDD 구현**: RED → GREEN → REFACTOR 사이클 반복 +3. **품질 검증**: TRUST 5원칙 준수 확인 +4. **문서 동기화**: `/alfred:3-sync` 실행 +5. **PR 머지**: 자동 또는 수동 머지 후 develop 체크아웃 + +--- + +**작성일**: 2025-10-14 +**작성자**: @edward (spec-builder 에이전트) +**관련 SPEC**: SPEC-RECEIPT-001.md