417 lines
16 KiB
Dart
417 lines
16 KiB
Dart
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<void> _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<void> _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<void>(
|
|
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<void> _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<String>),
|
|
),
|
|
);
|
|
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<BackendException>()),
|
|
);
|
|
});
|
|
});
|
|
|
|
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<TranslationCommentCard>(
|
|
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<void> appMenu(String item) async {
|
|
await tester.tap(
|
|
find.descendant(
|
|
of: find.byType(AppBar),
|
|
matching: find.byType(PopupMenuButton<String>),
|
|
),
|
|
);
|
|
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 = <String>[];
|
|
final liked = <(String, bool)>[];
|
|
final blockedCreators = <String>[];
|
|
|
|
@override
|
|
Future<void> likeComment(
|
|
String campaignId,
|
|
String commentId,
|
|
bool on,
|
|
) async => liked.add((commentId, on));
|
|
|
|
@override
|
|
Future<void> reportCampaign(String campaignId, String reason) async =>
|
|
reported.add('campaign:$campaignId');
|
|
|
|
@override
|
|
Future<void> blockCreator(CampaignCardData card) async =>
|
|
blockedCreators.add(card.id);
|
|
|
|
@override
|
|
Future<void> reportComment(
|
|
String campaignId,
|
|
CampaignComment comment,
|
|
String reason,
|
|
) async => reported.add(comment.id);
|
|
}
|
|
|
|
class _Paged extends SampleCampaignRepository {
|
|
@override
|
|
Future<List<CampaignComment>> 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<bool> hasPublicNickname() async => named;
|
|
}
|