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

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
+256
View File
@@ -0,0 +1,256 @@
import 'dart:convert';
import 'dart:typed_data';
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/my/my_data.dart';
import 'package:fancake/features/my/photo_picker.dart';
import 'package:fancake/features/planet/lounge_post_screens.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/planet/planet_repository_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// 팬덤 라운지 쓰기 경로 — 글 · 좋아요 · 댓글 · 신고 · 차단이 서버 문서로 남는가.
///
/// 규칙 판정(본인 · 팬덤 회원 · 집계 필드 위조)은 `firebase/test` 의 규칙
/// 테스트가 본다. 여기서는 앱이 **규칙이 받는 모양으로** 쓰는지를 본다.
const _me = 'u-me';
Future<(FakeFirebaseFirestore, FirestorePlanetRepository)> _repo() async {
final db = FakeFirebaseFirestore();
// displayName 은 공급자 실명 자리 — 라운지 이름은 닉네임에서만 나온다(M-7).
await db.doc('users/$_me').set({
'displayName': '김실명',
'nickname': '나',
'fandomId': 'bts',
});
final backend = Backend(
firestore: db,
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _me)),
);
return (db, FirestorePlanetRepository(backend, fandomId: 'bts'));
}
/// 1×1 PNG. 화면이 미리보기를 그려야 해서 실제로 디코딩되는 바이트를 쓴다.
final _png = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
);
PickedPhoto _photo([int? size]) => PickedPhoto(
bytes: size == null ? _png : Uint8List(size),
contentType: 'image/png',
name: 'a.png',
);
void main() {
group('라운지 쓰기 — Firestore', () {
test('글은 규칙이 받는 필드로만 남고 목록에 바로 뜬다', () async {
final (db, repo) = await _repo();
final post = await repo.createPost('첫 글');
final data = (await db.doc('posts/${post.id}').get()).data()!;
expect(data.keys.toSet(), {
'fandomId', 'authorUid', 'authorName', 'body', 'imageUrls',
'likeCount', 'commentCount', 'visibility', 'createdAt', 'updatedAt',
});
expect(data['authorName'], '나');
expect(data['visibility'], 'visible');
expect(data['likeCount'], 0);
expect(post.isMine, isTrue);
final list = await repo.morePosts(offset: 0);
expect(list.map((p) => p.id), [post.id]);
});
test('닉네임이 없으면 작성자 이름은 실명이 아니라 「팬」이다', () async {
final (db, repo) = await _repo();
await db.doc('users/$_me').update({'nickname': FieldValue.delete()});
final post = await repo.createPost('글');
expect(post.authorName, '팬');
final c = await repo.addComment(post.id, '댓글');
expect(c.authorName, '팬');
final data = (await db.doc('posts/${post.id}').get()).data()!;
expect(data['authorName'], '팬');
});
test('사진 6장 · 10MB 초과는 업로드 전에 거른다(B-44)', () async {
final (db, repo) = await _repo();
await expectLater(
repo.createPost('x', photos: List.filled(6, _photo())),
throwsA(isA<PhotoRejected>()),
);
await expectLater(
repo.createPost('x', photos: [_photo(10 * 1024 * 1024 + 1)]),
throwsA(isA<PhotoRejected>()),
);
expect((await db.collection('posts').get()).docs, isEmpty);
});
test('수정 · 삭제는 본문과 문서에 반영된다', () async {
final (db, repo) = await _repo();
final post = await repo.createPost('before');
final edited = await repo.updatePost(post, 'after');
expect(edited.body, 'after');
expect((await db.doc('posts/${post.id}').get()).data()!['body'], 'after');
await repo.deletePost(edited);
expect((await db.doc('posts/${post.id}').get()).exists, isFalse);
});
test('좋아요는 likes/{uid} 한 건을 켜고 끄며 수는 쓰지 않는다', () async {
final (db, repo) = await _repo();
final post = await repo.createPost('글');
final liked = await repo.toggleLike(post);
expect(liked.liked, isTrue);
expect(liked.likes, 1);
expect((await db.doc('posts/${post.id}/likes/$_me').get()).exists, isTrue);
// 집계는 서버 트리거 몫 — 앱은 likeCount 를 건드리지 않는다.
expect((await db.doc('posts/${post.id}').get()).data()!['likeCount'], 0);
expect((await repo.morePosts(offset: 0)).single.liked, isTrue);
final unliked = await repo.toggleLike(liked);
expect(unliked.liked, isFalse);
expect((await db.doc('posts/${post.id}/likes/$_me').get()).exists, isFalse);
});
test('댓글은 쓰고 읽고 내 것만 지운다', () async {
final (db, repo) = await _repo();
final post = await repo.createPost('글');
final c = await repo.addComment(post.id, '댓글');
expect(c.isMine, isTrue);
await db.collection('posts/${post.id}/comments').add({
'authorUid': 'other', 'authorName': '남', 'body': '숨김',
'visibility': 'hidden', 'createdAt': DateTime(2026),
});
final list = await repo.comments(post.id);
expect(list.map((c) => c.body), ['댓글']);
await repo.deleteComment(post.id, c.id);
expect(await repo.comments(post.id), isEmpty);
});
test('신고는 reports 에 대상 + 신고자 ID 로 한 건 쌓인다', () async {
final (db, repo) = await _repo();
final post = await repo.createPost('글');
await repo.reportPost(post, '부적절한 콘텐츠');
final report =
(await db.doc('reports/post-${post.id}-$_me').get()).data()!;
expect(report['reporterUid'], _me);
expect(report['targetType'], 'post');
expect(report['targetPath'], 'posts/${post.id}');
// 화면 문구가 아니라 규칙이 받는 코드로 적는다.
expect(report['reasonCode'], 'inappropriate');
await expectLater(
repo.reportPost(post, '목록에 없는 사유'),
throwsA(isA<BackendException>()),
);
expect(report['status'], 'pending');
});
test('차단한 작성자의 글 · 댓글은 목록에서 빠진다', () async {
final (db, repo) = await _repo();
for (var i = 0; i < 6; i++) {
await db.collection('posts').add({
'fandomId': 'bts', 'visibility': 'visible',
'authorUid': 'troll', 'authorName': '트롤',
'body': '$i', 'createdAt': DateTime(2026, 9, 2, i),
});
}
final good = await db.collection('posts').add({
'fandomId': 'bts', 'visibility': 'visible',
'authorUid': 'fan', 'authorName': '팬', 'body': '정상',
'createdAt': DateTime(2026, 9, 1),
});
await db.collection('posts/${good.id}/comments').add({
'authorUid': 'troll', 'authorName': '트롤', 'body': '악플',
'visibility': 'visible', 'createdAt': DateTime(2026),
});
final first = await repo.morePosts(offset: 0);
await repo.blockAuthor(first.first);
expect((await db.doc('users/$_me/blocks/troll').get()).exists, isTrue);
// 한 장(5개)이 통째로 차단 글이어도 다음 장까지 읽어 정상 글을 낸다.
final after = await repo.morePosts(offset: 0);
expect(after.map((p) => p.body), ['정상']);
expect(await repo.comments(good.id), isEmpty);
});
});
test('운영 사칭 닉네임은 대소문자와 무관하게 막는다(규칙과 같은 목록)', () {
for (final n in ['FANUZU팬', 'Admin99', 'xOfficialx', '운영팀장', '관리자']) {
expect(nicknameReserved(n), isTrue, reason: n);
}
expect(nicknameReserved('평범한팬'), isFalse);
for (final n in [' 팬 ', '\u3000팬', '팬\u200B', 'Fan', ' FAN', 'ファン', '粉丝', 'ad\u200Dmin', 'FANU\uFEFFZU']) {
expect(nicknameReserved(n), isTrue, reason: n);
}
expect(nicknameReserved('팬팬'), isFalse);
expect(nicknameReserved('Fans'), isFalse);
});
group('라운지 쓰기 — 화면', () {
Future<void> pump(WidgetTester tester, Widget screen) async {
await tester.pumpWidget(MaterialApp(home: screen));
await tester.pumpAndSettle();
}
testWidgets('6번째 사진은 붙지 않고 사유를 띄운다', (tester) async {
await pump(
tester,
LoungeComposeScreen(picker: (PhotoSource _) async => _photo()),
);
for (var i = 0; i < 6; i++) {
await tester.tap(find.bySemanticsLabel('앨범'));
await tester.pumpAndSettle();
}
expect(find.bySemanticsLabel('사진 5 빼기'), findsOneWidget);
expect(find.bySemanticsLabel('사진 6 빼기'), findsNothing);
expect(find.text('사진은 5장까지 올릴 수 있습니다.'), findsOneWidget);
});
testWidgets('저장 실패면 화면에 남아 사유를 띄운다', (tester) async {
await pump(
tester,
LoungeComposeScreen(
onSubmit: (_) async => throw const PhotoRejected('용량 초과'),
),
);
await tester.enterText(find.byType(TextField), '본문');
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '완료'));
await tester.pumpAndSettle();
expect(find.text('용량 초과'), findsOneWidget);
expect(find.byType(LoungeComposeScreen), findsOneWidget);
});
testWidgets('댓글을 보내면 목록에 붙고 수가 늘어 알린다', (tester) async {
LoungePost? changed;
final post = LoungePost(
id: 'widget-${DateTime.now().microsecondsSinceEpoch}',
authorName: '팬',
createdAt: DateTime(2026, 9, 1),
title: '',
body: '본문',
likes: 0,
comments: 0,
);
await pump(
tester,
LoungePostScreen(post: post, onChanged: (p) => changed = p),
);
expect(find.text('첫 댓글을 남겨주세요.'), findsOneWidget);
await tester.enterText(find.byType(TextField), '반가워요');
await tester.tap(find.byTooltip('보내기'));
await tester.pumpAndSettle();
expect(find.text('반가워요'), findsOneWidget);
expect(changed?.comments, 1);
await tester.tap(find.bySemanticsLabel('좋아요'));
await tester.pumpAndSettle();
expect(changed?.liked, isTrue);
expect(changed?.likes, 1);
});
});
}