257 lines
10 KiB
Dart
257 lines
10 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|