import 'package:cloud_firestore/cloud_firestore.dart' show FieldValue; import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; import 'package:fancake/core/data/backend.dart'; import 'package:fancake/features/campaign/campaign_data.dart'; import 'package:fancake/features/campaign/campaign_detail_screen.dart'; import 'package:fancake/features/campaign/campaign_repository_firestore.dart'; import 'package:fancake/features/my/profile_edit_screen.dart'; import 'package:fancake/features/translation/translation_comments.dart'; import 'package:fancake/features/translation/translation_data.dart'; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; /// 캠페인 응원 게시판 댓글 — 서버 문서 모양 · 최신순 · 페이지 · 신고 · 차단. /// /// 규칙 판정(공개 캠페인 · 표시명 · 시각 · 1,000자 · 신고 ID)은 `firebase/test` 의 /// 규칙 테스트가, 댓글 수 집계는 functions 테스트가 본다. const _me = 'u-me'; Future<(FakeFirebaseFirestore, FirestoreCampaignRepository)> _repo() async { final db = FakeFirebaseFirestore(); await db.doc('users/$_me').set({'displayName': '김실명', 'nickname': '나'}); await db.doc('campaigns/c1').set({'status': 'funding', 'commentCount': 3}); final backend = Backend( firestore: db, auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _me)), ); return (db, FirestoreCampaignRepository(backend)); } Future _seed(FakeFirebaseFirestore db, String uid, int i) => db.collection('campaigns/c1/comments').add({ 'authorUid': uid, 'authorName': uid, 'body': '$uid$i', 'visibility': 'visible', 'createdAt': DateTime(2026, 9, 1).add(Duration(minutes: i)), }); Future _pump( WidgetTester tester, CampaignRepository repo, { TranslationRepository? translation, bool board = true, }) async { tester.view.physicalSize = const Size(1080, 2600); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); // 차단하면 상세가 닫히므로 아래에 첫 화면을 하나 둔다. await tester.pumpWidget( MaterialApp( home: Builder( builder: (context) => TextButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => CampaignDetailScreen( campaignId: 'c1', repository: repo, translation: translation, ), ), ), child: const Text('open'), ), ), ), ); await tester.tap(find.text('open')); await tester.pumpAndSettle(); if (!board) return; await tester.tap(find.textContaining('응원 게시판')); await tester.pumpAndSettle(); } Future _menu(WidgetTester tester, String body, String item) async { await tester.tap( find.descendant( of: find.ancestor(of: find.text(body), matching: find.byType(Row)).first, matching: find.byType(PopupMenuButton), ), ); await tester.pumpAndSettle(); await tester.tap(find.text(item).last); await tester.pumpAndSettle(); } void main() { group('응원 게시판 — Firestore', () { test('댓글은 규칙이 받는 필드로만, 공개 닉네임으로 남는다', () async { final (db, repo) = await _repo(); final c = await repo.addComment('c1', '응원해요'); final data = (await db.doc('campaigns/c1/comments/${c.id}').get()) .data()!; expect(data.keys.toSet(), { 'authorUid', 'authorName', 'body', 'visibility', 'createdAt', 'updatedAt', }); expect(data['authorName'], '나'); expect(c.isMine, isTrue); // 작성 자격 — 공개 닉네임. 비었거나 이메일 형태면 없다(규칙 hasPublicNickname). expect(await repo.hasPublicNickname(), isTrue); await db.doc('users/$_me').update({'nickname': 'me@mail.com'}); expect(await repo.hasPublicNickname(), isFalse); await db.doc('users/$_me').update({'nickname': FieldValue.delete()}); expect(await repo.hasPublicNickname(), isFalse); await repo.deleteComment('c1', c.id); expect( (await db.doc('campaigns/c1/comments/${c.id}').get()).exists, isFalse, ); }); test('탭의 댓글 수는 서버가 센 commentCount 다', () async { final (_, repo) = await _repo(); expect((await repo.detail('c1')).card.comments, 3); }); test('최신순으로 한 장씩 읽고, 숨김 · 차단 작성자의 댓글은 뺀다', () async { final (db, repo) = await _repo(); for (var i = 0; i < 25; i++) { await _seed(db, 'fan', i); } await db.collection('campaigns/c1/comments').add({ 'authorUid': 'fan', 'body': '숨김', 'visibility': 'hidden', 'createdAt': DateTime(2027), }); final first = await repo.comments('c1'); expect(first.length, campaignCommentPageSize); expect(first.first.body, 'fan24'); final next = await repo.comments('c1', more: true); expect(next.map((c) => c.body), ['fan4', 'fan3', 'fan2', 'fan1', 'fan0']); await _seed(db, 'troll', 99); final troll = (await repo.comments('c1')).first; expect(troll.body, 'troll99'); await repo.blockAuthor(troll); expect((await db.doc('users/$_me/blocks/troll').get()).exists, isTrue); expect((await repo.comments('c1')).first.body, 'fan24'); }); test('신고는 reports 에 댓글 + 신고자 ID 로 쌓인다', () async { final (db, repo) = await _repo(); await _seed(db, 'troll', 1); final c = (await repo.comments('c1')).single; await repo.reportComment('c1', c, '스팸 및 영리 목적 광고'); final report = (await db.doc('reports/campaignComment-c1-${c.id}-$_me').get()) .data()!; expect(report['targetType'], 'campaignComment'); expect(report['targetPath'], 'campaigns/c1/comments/${c.id}'); expect(report['reasonCode'], 'spam'); expect(report['status'], 'pending'); expect( repo.reportComment('c1', c, '목록에 없는 사유'), throwsA(isA()), ); }); }); group('좋아요 · 캠페인 신고 · 개설자 차단 — Firestore', () { test('댓글 좋아요는 내 문서 하나로 남고, 목록은 서버 수와 내 상태를 읽는다', () async { final (db, repo) = await _repo(); await _seed(db, 'fan', 1); var c = (await repo.comments('c1')).single; expect((c.likes, c.liked), (0, false)); await repo.likeComment('c1', c.id, true); final like = await db .doc('campaigns/c1/comments/${c.id}/likes/$_me') .get(); expect(like.data()!.keys, ['createdAt']); // 수는 서버 트리거가 적는다. await db.doc('campaigns/c1/comments/${c.id}').update({'likeCount': 1}); c = (await repo.comments('c1')).single; expect((c.likes, c.liked), (1, true)); await repo.likeComment('c1', c.id, false); expect((await repo.comments('c1')).single.liked, isFalse); }); test('캠페인 신고는 campaign-{캠페인}-{신고자} 로 남는다', () async { final (db, repo) = await _repo(); await repo.reportCampaign('c1', '스팸 및 영리 목적 광고'); final r = (await db.doc('reports/campaign-c1-$_me').get()).data()!; expect(r['targetType'], 'campaign'); expect(r['targetPath'], 'campaigns/c1'); expect(r['reasonCode'], 'spam'); }); test('개설자를 차단하면 그 캠페인과 그 사람의 댓글이 빠진다', () async { final (db, repo) = await _repo(); await db.doc('campaigns/c1').update({'creatorUid': 'maker'}); await db.doc('campaigns/c2').set({ 'status': 'funding', 'creatorUid': 'other', }); await _seed(db, 'maker', 1); final card = (await repo.detail('c1')).card; expect(card.creatorUid, 'maker'); await repo.blockCreator(card); expect((await db.doc('users/$_me/blocks/maker').get()).exists, isTrue); expect((await repo.load()).map((c) => c.id), ['c2']); expect(await repo.comments('c1'), isEmpty); // 차단 목록은 읽을 때마다 새로 읽는다 — MY 에서 풀면 바로 다시 보인다. await db.doc('users/$_me/blocks/maker').delete(); expect((await repo.load()).map((c) => c.id).toSet(), {'c1', 'c2'}); expect(await repo.comments('c1'), hasLength(1)); }); }); group('응원 게시판 — 화면', () { testWidgets('기본 입력은 일반 댓글이고 맨 위에 붙어 탭 수가 는다', (tester) async { final repo = SampleCampaignRepository(); await _pump(tester, repo); expect(find.text('응원 게시판 24'), findsOneWidget); await tester.enterText(find.byType(TextField), '화이팅'); await tester.tap(find.byTooltip('보내기')); await tester.pumpAndSettle(); expect(find.text('화이팅'), findsOneWidget); expect(find.text('응원 게시판 25'), findsOneWidget); expect( tester.getTopLeft(find.text('화이팅')).dy, lessThan(tester.getTopLeft(find.text('꼭 성공했으면 좋겠어요!')).dy), ); // 내 댓글은 삭제만 — 확인하면 목록과 수에서 빠진다. await _menu(tester, '화이팅', '삭제하기'); await tester.tap(find.text('확인')); await tester.pumpAndSettle(); expect(find.text('화이팅'), findsNothing); expect(find.text('응원 게시판 24'), findsOneWidget); }); testWidgets('남의 댓글은 신고 · 차단 — 차단하면 그 작성자의 댓글이 사라진다', (tester) async { final repo = _Spy(); await _pump(tester, repo); await _menu(tester, '꼭 성공했으면 좋겠어요!', '신고하기'); await tester.tap(find.text('스팸 및 영리 목적 광고')); await tester.pumpAndSettle(); await tester.tap(find.text('신고')); await tester.pumpAndSettle(); await tester.tap(find.text('확인')); await tester.pumpAndSettle(); expect(repo.reported, ['m1']); expect(find.text('신고를 접수했습니다.'), findsOneWidget); await _menu(tester, '꼭 성공했으면 좋겠어요!', '차단하기'); await tester.tap(find.text('확인')); await tester.pumpAndSettle(); expect(find.text('꼭 성공했으면 좋겠어요!'), findsNothing); expect(find.text('조감도 너무 예쁘네요'), findsOneWidget); }); testWidgets('한 장이 꽉 차면 더 보기로 다음 장을 붙인다', (tester) async { await _pump(tester, _Paged()); expect(find.text('p0'), findsOneWidget); expect(find.text('next'), findsNothing); await tester.ensureVisible(find.text('더 보기')); await tester.pumpAndSettle(); await tester.tap(find.text('더 보기')); await tester.pumpAndSettle(); expect(find.text('next'), findsOneWidget); expect(find.text('더 보기'), findsNothing); }); testWidgets('댓글 좋아요는 저장 뒤에 수가 바뀌고 다시 누르면 취소된다', (tester) async { final repo = _Spy(); await _pump(tester, repo); // 댓글 카드의 하트는 14 — 캠페인 본문의 하트(16)와 가른다. Finder heart(IconData icon) => find.byWidgetPredicate( (w) => w is Icon && w.icon == icon && w.size == 14, ); await tester.tap(heart(Icons.favorite_border).first); await tester.pumpAndSettle(); expect(repo.liked, [('m1', true)]); expect(heart(Icons.favorite), findsOneWidget); await tester.tap(heart(Icons.favorite)); await tester.pumpAndSettle(); expect(repo.liked.last, ('m1', false)); expect(heart(Icons.favorite), findsNothing); }); testWidgets('댓글의 지구본으로 그 댓글을 번역하면 대댓글 자리에 붙는다', (tester) async { await _pump( tester, SampleCampaignRepository(), translation: SampleTranslationRepository(uid: 'me'), ); await tester.tap(find.byTooltip('번역 댓글 작성').first); await tester.pumpAndSettle(); expect(find.textContaining('님 댓글 번역'), findsOneWidget); await tester.enterText(find.byType(TextField), 'Good luck'); await tester.tap(find.byTooltip('보내기')); await tester.pumpAndSettle(); final card = tester.widget( find.byType(TranslationCommentCard), ); expect(card.entry.targetPath, 'campaigns/c1/comments/m1'); expect(card.reply, isTrue); }); testWidgets('캠페인 신고는 저장소에 남고, 개설자 차단은 상세를 닫는다', (tester) async { final repo = _Spy(); await _pump(tester, repo, board: false); Future appMenu(String item) async { await tester.tap( find.descendant( of: find.byType(AppBar), matching: find.byType(PopupMenuButton), ), ); await tester.pumpAndSettle(); await tester.tap(find.text(item).last); await tester.pumpAndSettle(); } await appMenu('신고하기'); await tester.tap(find.text('스팸 및 영리 목적 광고')); await tester.pumpAndSettle(); await tester.tap(find.text('신고')); await tester.pumpAndSettle(); await tester.tap(find.text('확인')); await tester.pumpAndSettle(); expect(repo.reported, ['campaign:c1']); await appMenu('차단하기'); await tester.tap(find.text('확인')); await tester.pumpAndSettle(); expect(repo.blockedCreators, ['c1']); expect(find.byType(CampaignDetailScreen), findsNothing); }); testWidgets('닉네임이 없으면 입력 대신 안내와 닉네임 정하기 버튼이 뜬다', (tester) async { final repo = _NoNickname(); await _pump(tester, repo); expect(find.text('닉네임을 먼저 정해 주세요'), findsOneWidget); expect(find.byType(CommentInputBar), findsNothing); await tester.tap(find.text('닉네임 정하기')); await tester.pumpAndSettle(); expect(find.byType(ProfileEditScreen), findsOneWidget); // 닉네임을 정하고 돌아오면 입력이 열린다. repo.named = true; Navigator.of(tester.element(find.byType(ProfileEditScreen))).pop(); await tester.pumpAndSettle(); expect(find.byType(CommentInputBar), findsOneWidget); }); }); } class _Spy extends SampleCampaignRepository { final reported = []; final liked = <(String, bool)>[]; final blockedCreators = []; @override Future likeComment( String campaignId, String commentId, bool on, ) async => liked.add((commentId, on)); @override Future reportCampaign(String campaignId, String reason) async => reported.add('campaign:$campaignId'); @override Future blockCreator(CampaignCardData card) async => blockedCreators.add(card.id); @override Future reportComment( String campaignId, CampaignComment comment, String reason, ) async => reported.add(comment.id); } class _Paged extends SampleCampaignRepository { @override Future> comments( String campaignId, { bool more = false, }) async => [ if (more) CampaignComment( id: 'n', authorName: 'b', body: 'next', createdAt: DateTime(2026), ) else for (var i = 0; i < campaignCommentPageSize; i++) CampaignComment( id: 'p$i', authorName: 'a', body: 'p$i', createdAt: DateTime(2026), ), ]; } class _NoNickname extends SampleCampaignRepository { bool named = false; @override Future hasPublicNickname() async => named; }