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()), ); await expectLater( repo.createPost('x', photos: [_photo(10 * 1024 * 1024 + 1)]), throwsA(isA()), ); 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()), ); 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 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); }); }); }