앱 단위·화면·통합 테스트

This commit is contained in:
Sn-Kinos
2026-10-04 01:43:10 +09:00
parent de44739e63
commit 4f6e1eac61
63 changed files with 14700 additions and 230 deletions
+416
View File
@@ -0,0 +1,416 @@
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;
}