import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; import 'package:fancake/core/data/backend.dart'; import 'package:fancake/core/data/repositories.dart'; import 'package:fancake/features/campaign/campaign_repository_firestore.dart'; import 'package:fancake/features/galaxy/galaxy_data.dart'; import 'package:fancake/features/galaxy/galaxy_repository_firestore.dart'; import 'package:fancake/features/home/home_data.dart'; import 'package:fancake/features/home/home_repository_firestore.dart'; import 'package:fancake/core/push/push_messaging.dart'; import 'package:fancake/core/push/push_transport.dart'; import 'package:fancake/features/my/my_data.dart'; import 'package:fancake/features/my/my_repository_firestore.dart'; import 'package:fancake/features/notification/notification_data.dart'; import 'package:fancake/features/planet/planet_data.dart'; import 'package:fancake/features/planet/planet_repository_firestore.dart'; import 'package:fancake/features/quest/quest_data.dart'; import 'package:fancake/features/quest/quest_repository_firestore.dart'; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter_test/flutter_test.dart'; /// 서버 연동 계층 검증 — 화면 대조 원장 F1·F2·F3 의 데이터 경로. /// /// 「서버 문서를 화면 값으로 옮기는 규칙」이 맞는지를 본다. 규칙이 틀리면 화면은 /// 조용히 0 을 그리므로 눈으로는 잡히지 않는다. const _uid = 'u-test'; Backend _backend(FakeFirebaseFirestore db) => Backend( firestore: db, auth: MockFirebaseAuth( signedIn: true, mockUser: MockUser(uid: _uid, displayName: '테스터'), ), ); /// 첫 호출은 응답 유실처럼 던지고 이후는 성공하는 경계 — 후원 재시도 번호 검증용. class _FlakyBackend extends Backend { _FlakyBackend(FakeFirebaseFirestore db) : super( firestore: db, auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)), ); final List> calls = []; @override Future> call( String name, [ Map payload = const {}, ]) async { calls.add(payload); if (calls.length == 1) throw const BackendException('E-SYS-01', '응답 유실'); return const {}; } } Future _seed() async { final db = FakeFirebaseFirestore(); await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts'}); await db.doc('users/$_uid/wallet/main').set({ 'activityBalance': 12540, 'supportBalance': 300, 'pendingActivity': 0, }); await db.doc('fandoms/bts').set({ 'name': 'BTS', 'stage': 1, 'growthPop': 6270, 'sfpi': 750, 'lastContributorName': 'BTS_Hong', 'lastContributedPop': 1230, 'fpiScore': 284310, 'memberCount': 12840, 'onlineCount': 421, 'liveTicker': '[LIVE] 버프가 발동되었습니다!', }); await db.doc('fandoms/bts/planets/$_uid').set({ 'stage': 1, 'lastContributorName': 'BTS_Hong', 'lastContributedPop': 1230, }); // 누적 기여 POP 은 멤버 문서에 쌓인다 — `fn-pop-spend` 가 기여 차감과 같은 // 트랜잭션에서 올리는 유일한 필드다(설계서 §3.6). await db.doc('fandoms/bts/members/$_uid').set({'contributedPop': 6270}); // 다음 단계 문턱은 마스터 데이터다(설계서 §2.3 `planetStages`). await db.doc('planetStages/2').set({'stageNo': 2, 'requiredPop': 10000, 'requiredSfpi': 1000}); return db; } void main() { group('홈 — Firestore 구현', () { test('공동 성장 변경과 Stage 승급을 다시 load하지 않고 받는다', () async { final db = await _seed(); final values = StreamIterator(FirestoreHomeRepository(_backend(db)).watchGrowth('bts')); addTearDown(values.cancel); expect(await values.moveNext(), isTrue); expect(values.current.currentPop, 6270); expect(values.current.sfpiConditionLabel, 'sFPI 조건 미충족'); await db.doc('fandoms/bts').update({'growthPop': 10000, 'sfpi': 1000}); expect(await values.moveNext(), isTrue); expect(values.current.growthStatus, '승급 확정 대기'); await db.doc('planetStages/3').set({'requiredPop': 20000, 'requiredSfpi': 2000}); await db.doc('fandoms/bts').update({'stage': 2}); expect(await values.moveNext(), isTrue); expect(values.current.stage, 2); expect(values.current.requiredPop, 20000); expect(values.current.popConditionLabel, 'POP 조건 미충족'); }); test('홈과 상세는 같은 팬덤의 서버 Stage를 표시하고 개인 Stage를 섞지 않는다', () async { final db = await _seed(); await db.doc('fandoms/bts').update({'stage': 4, 'colorKey': 'gold'}); final home = await FirestoreHomeRepository(_backend(db)).load(); final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') .load(nebula: true); final galaxy = await FirestoreGalaxyRepository(_backend(db)).load(); expect(galaxy.mine!.stage, 4); expect(galaxy.mine!.colorKey, 'gold'); expect(home.growth.stage, 4); expect(detail.stage, 4); expect(detail.isNebula, false); await db.doc('fandoms/bts').update({'stage': 0}); final nebula = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') .load(nebula: false); expect(nebula.stage, 0); expect(nebula.isNebula, true); }); test('서버에 단계가 없으면 시작 단계로 지어내지 않는다', () async { final db = await _seed(); await db.doc('fandoms/bts').update({'stage': FieldValue.delete()}); final home = await FirestoreHomeRepository(_backend(db)).load(); expect(home.growth.stage, isNull); }); test('목표 기간이 지난 성운은 기간 경과 표시 · 전환 차단 설정이 켜지면 표시하지 않는다(Q-PN-02)', () async { final db = await _seed(); await db.doc('fandoms/bts').update({ 'stage': 0, 'nebulaDeadline': Timestamp.fromDate( DateTime.now().subtract(const Duration(days: 1)), ), }); final repo = FirestoreHomeRepository(_backend(db)); expect((await repo.load()).growth.nebulaGoalPassed, isTrue); await db.doc('config/policy').set({ 'nebulaConversionDeadlineEnforced': true, }); expect((await repo.load()).growth.nebulaGoalPassed, isFalse); await db.doc('config/policy').delete(); await db.doc('fandoms/bts').update({ 'nebulaDeadline': Timestamp.fromDate( DateTime.now().add(const Duration(days: 1)), ), }); expect((await repo.load()).growth.nebulaGoalPassed, isFalse); }); test('지갑·행성·팬덤을 화면 값으로 옮긴다', () async { final db = await _seed(); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.popBalance, 12540); expect(snapshot.liveTicker, contains('버프')); expect(snapshot.growth.fandomName, 'BTS'); expect(snapshot.growth.contributorName, 'BTS_Hong'); expect(snapshot.growth.currentPop, 6270); // 서버가 growthPercent 를 주지 않으면 진행도로 낸다. expect(snapshot.growth.growthPercent, 63); }); test('개인 행성의 성장률을 공동 성장률로 사용하지 않는다', () async { final db = await _seed(); await db.doc('fandoms/bts/planets/$_uid') .set({'growthPercent': 67}, SetOptions(merge: true)); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.growth.growthPercent, 63); }); test('공동 성장 값 누락은 개인 값으로 채우지 않고 sFPI 조건을 따로 읽는다', () async { final db = await _seed(); await db.doc('fandoms/bts').update({'growthPop': FieldValue.delete()}); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.growth.currentPop, isNull); expect(snapshot.growth.growthPercent, isNull); expect(snapshot.growth.currentSfpi, 750); expect(snapshot.growth.requiredSfpi, 1000); expect(snapshot.growth.sfpiConditionLabel, 'sFPI 조건 미충족'); }); test('다음 Stage 조건은 공동 Stage 기준이며 개인 Stage와 무관하다', () async { final db = await _seed(); await db.doc('fandoms/bts').update({'stage': 4, 'growthPop': 30000, 'sfpi': 500}); await db.doc('planetStages/5').set({'requiredPop': 25000, 'requiredSfpi': 1000}); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.growth.requiredPop, 25000); expect(snapshot.growth.popConditionLabel, 'POP 조건 충족'); expect(snapshot.growth.sfpiConditionLabel, 'sFPI 조건 미충족'); expect(snapshot.growth.growthStatus, isNot('승급 확정 대기')); }); test('진행 횟수가 한도를 채우면 완료로 본다', () async { final db = await _seed(); await db.doc('quests/attend').set({ 'active': true, 'title': '오늘의 출석체크', 'rewardPop': 50, 'dailyLimit': 1, }); await db.doc('users/$_uid/questProgress/attend') .set({'count': 1, 'dayKey': kstDayKey()}); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.quests.single.state, QuestState.done); expect(snapshot.quests.single.rewardPop, 50); }); test('지난 날의 진행 행은 완료로 보지 않는다 — 목록 · 상세와 같은 판정(실기기 4-1)', () async { final db = await _seed(); await db.doc('quests/attend').set({ 'active': true, 'type': 'attend', 'title': '출석 체크', 'dailyLimit': 1, }); await db.doc('users/$_uid/questProgress/attend') .set({'count': 1, 'dayKey': '2026-09-21'}); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.quests.single.state, isNot(QuestState.done)); }); test('출석 보상은 서버 지급과 같은 config/policy.attendPop 을 쓴다', () async { final db = await _seed(); await db.doc('quests/attend').set({ 'active': true, 'type': 'attend', 'title': '오늘의 출석체크', 'rewardPop': 50, 'dailyLimit': 1, }); await db.doc('config/policy').set({'attendPop': 10}); var snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.quests.single.rewardPop, 10); // 운영값이 없으면 숫자를 지어내지 않는다. await db.doc('config/policy').delete(); snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.quests.single.rewardPop, isNull); }); test('캠페인 달성률은 목표 대비로 낸다', () async { final db = await _seed(); await db.doc('campaigns/c1').set({ 'status': 'funding', 'authorName': '다이브짱', 'title': '강남역 LED', 'description': '도심을 물들여 주세요', 'goalPop': 10000, 'raisedPop': 9200, 'donorCount': 48, 'commentCount': 24, 'likeCount': 82, }); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.campaigns.first.achievedPercent, 92); expect(snapshot.campaigns.first.supporters, 48); }); test('차단한 개설자의 캠페인은 홈 대표 캠페인에서 건너뛴다', () async { final db = await _seed(); await db.doc('campaigns/top').set({ 'status': 'funding', 'creatorUid': 'troll', 'title': '차단됨', 'targetPop': 100, 'raisedPop': 90, }); await db.doc('campaigns/next').set({ 'status': 'funding', 'creatorUid': 'fan', 'title': '다음', 'targetPop': 100, 'raisedPop': 50, }); await db.doc('users/$_uid/blocks/troll').set({'nickname': '트롤'}); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.campaigns.first.title, '다음'); }); test('모금 중이 아닌 캠페인은 홈에 올리지 않는다', () async { final db = await _seed(); await db.doc('campaigns/c2').set({ 'status': 'review', 'title': '심사 중', 'goalPop': 100, 'raisedPop': 0, }); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.campaigns, isEmpty); }); }); group('갤럭시 — Firestore 구현', () { test('FPI 내림차순이 곧 순위이고 내 팬덤이 한가운데 선다', () async { final db = await _seed(); await db.doc('fandoms/nj').set({ 'name': 'NewJeans', 'fpiScore': 310000, 'memberCount': 9000, }); await db.doc('fandoms/svt').set({ 'name': 'SEVENTEEN', 'fpiScore': 120000, 'memberCount': 7000, }); final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); expect(snapshot.planets.map((p) => p.name).toList(), ['NewJeans', 'BTS', 'SEVENTEEN']); expect(snapshot.planets.map((p) => p.rank).toList(), [1, 2, 3]); expect(snapshot.mine!.name, 'BTS'); expect(snapshot.myPop, 12540); expect(snapshot.joined, isTrue); }); // 원장 B-08 · B-17 — 성운은 랭킹 모집단이 아니다. 끼면 정식 행성 번호가 밀린다. test('Stage0 성운은 순위를 매기기 전에 빠지고 맨 바깥 궤도 자리에 선다', () async { final db = await _seed(); await db.doc('fandoms/neb').set({ 'name': '성운', 'stage': 0, 'fpiScore': 999999, 'memberCount': 3, }); await db.doc('fandoms/svt').set({ 'name': 'SEVENTEEN', 'stage': 1, 'fpiScore': 120000, 'memberCount': 7000, }); final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); final rank = {for (final p in snapshot.planets) p.id: p.rank}; expect(rank, {'bts': 1, 'svt': 2, 'neb': 3}); expect(snapshot.planets.firstWhere((p) => p.id == 'neb').rankLabel, '-'); }); // 원장 B-17 — 최근 7일 유효 활동이 없는 정식 행성(서버 rankingEligible: false)도 순위 밖이다. test('최근 7일 활동이 없는 행성은 순위 없이 바깥 궤도에 서고 Ranking View 중심이 되지 않는다', () async { final db = await _seed(); await db.doc('fandoms/idle').set({ 'name': '휴면', 'stage': 2, 'fpiScore': 999999, 'rankingEligible': false, }); await db.doc('fandoms/svt').set({ 'name': 'SEVENTEEN', 'stage': 1, 'fpiScore': 120000, 'rankingEligible': true, }); final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); final rank = {for (final p in snapshot.planets) p.id: (p.rank, p.rankLabel)}; expect(rank, {'bts': (1, '1'), 'svt': (2, '2'), 'idle': (3, '-')}); expect(snapshot.rankingView.planets.firstWhere((p) => p.isCenter).id, 'bts'); }); test('대표 팬덤은 primaryFandomId 가 정본이고 가입 목록은 최신 가입순이다(C-15 · §2.58)', () async { final db = await _seed(); await db.doc('fandoms/nj').set({'name': 'NewJeans', 'stage': 1, 'fpiScore': 1}); await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts', 'primaryFandomId': 'nj'}); await db.doc('users/$_uid/memberships/bts').set({'fandomId': 'bts', 'joinedAt': DateTime(2026, 9, 1)}); await db.doc('users/$_uid/memberships/nj').set({'fandomId': 'nj', 'joinedAt': DateTime(2026, 9, 20)}); final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); expect(snapshot.mine!.id, 'nj'); expect(snapshot.memberships.map((p) => (p.id, p.isMine)).toList(), [('nj', true), ('bts', false)]); }); test('팬덤에 속하지 않으면 Zap 을 내주지 않는다', () async { final db = await _seed(); await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': ''}); final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); expect(snapshot.joined, isFalse); expect(snapshot.mine, isNull); }); test('이클립스 이벤트를 띠 문구로 옮긴다', () async { final db = await _seed(); await db.collection('galaxyEvents').add({ 'kind': 'eclipse', 'message': 'BTS 행성에 이클립스가 발생했습니다', 'createdAt': DateTime(2026, 9, 1), }); final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); expect(snapshot.event!.kind, GalaxyEventKind.eclipse); }); }); test('행성 상세는 멤버 문서가 있을 때만 가입중이다', () async { final db = await _seed(); await db.doc('fandoms/nj').set({'name': 'NewJeans', 'stage': 1}); final mine = await FirestorePlanetRepository(_backend(db), fandomId: 'bts').load(nebula: false); final other = await FirestorePlanetRepository(_backend(db), fandomId: 'nj').load(nebula: false); expect(mine.joined, isTrue); expect(other.joined, isFalse); }); group('행성 상세 — Firestore 구현', () { Future seedPlanet() async { final db = await _seed(); // 현재값(1분 집계)은 팬덤 문서에 있고, Daily FPI Snapshot 은 일 1회다 // (별첨 B). 최근 동향 = 현재값 − 오늘의 스냅샷. // `fpiRanks`·`fpiScore` 는 서버(fn-fpi-aggregate)가 적는 값이다. 픽스처도 // 서버가 냈을 값과 같아야 한다 — 축 값과 순위가 어긋난 픽스처는 화면이 // 이미 어긋나 있어도 통과한다. 모집단은 eligible 인 bts·nj 둘뿐이다. await db.doc('fandoms/bts').set({ 'name': 'BTS', 'memberCount': 12840, 'rankingEligible': true, // 가중치 초기 1.0 이라 5축 합과 같다(별첨 B). 'fpiScore': 284310, 'fpiAxes': { 'bpm': 82400, 'health': 61200, 'referral': 38900, 'community': 55100, 'contribution': 46710, }, // bpm·referral 은 NewJeans 가 위, 나머지 세 축은 BTS 가 1위다. 'fpiRanks': { 'bpm': 2, 'health': 1, 'referral': 2, 'community': 1, 'contribution': 1, }, 'fpiRanksPrev': {'bpm': 3}, }); await db.doc('fandoms/nj').set({ 'name': 'NewJeans', 'rankingEligible': true, 'fpiScore': 272405, 'fpiAxes': { 'bpm': 91200, 'health': 44300, 'referral': 52800, 'community': 41600, 'contribution': 42005, }, 'fpiRanks': { 'bpm': 1, 'health': 2, 'referral': 1, 'community': 2, 'contribution': 2, }, }); // 최근 7일 유효 활동이 없어 모집단에서 빠진 행성(별첨 B). await db.doc('fandoms/quiet').set({ 'name': '조용한행성', 'rankingEligible': false, 'fpiAxes': {'bpm': 999999}, }); await db.doc('fandoms/bts/fpiSnapshots/2026-09-01-00').set({ 'fpiAxes': { 'bpm': 81160, 'health': 61520, 'referral': 38260, 'community': 54890, 'contribution': 45730, }, 'createdAt': DateTime(2026, 9, 1, 0), }); await db.doc('fandoms/bts/members/$_uid').set({ 'nickname': '나', // 국가 랭킹의 「나의 랭킹 영역」이 읽는 비정규화 국가 코드. 'countryCode': 'KR', 'topPercentByAxis': {'bpm': 3, 'health': 12}, 'fpiAxes': {'bpm': 2740, 'referral': 3180}, }); await db.doc('fandoms/bts/members/other').set({ 'nickname': '버니즈', 'fpiAxes': {'bpm': 4820, 'referral': 1240}, }); for (var i = 0; i < 8; i++) { await db.collection('posts').add({ 'fandomId': 'bts', 'visibility': 'visible', 'authorName': '아미보이', 'title': '글 $i', 'body': '본문', 'likeCount': 40 - i, 'commentCount': 3, 'createdAt': DateTime(2026, 9, 1).subtract(Duration(hours: i)), }); } return db; } test('현재값에서 Daily Snapshot 을 뺀 것이 최근 동향이다', () async { final db = await seedPlanet(); final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') .load(nebula: false); final bpm = detail.scores.firstWhere((s) => s.metric == FpiMetric.bpm); expect(bpm.value, 82400); expect(bpm.trend, 1240); expect(bpm.rank, 2); expect(bpm.myTopPercent, 3); // 서버 산출값이 없는 축은 null 이다 — 100%(최하위)로 채우면 화면이 없는 // 값을 사실로 띄운다(대조표 3-5). final referral = detail.scores.firstWhere((s) => s.metric == FpiMetric.referral); expect(referral.myTopPercent, isNull); // 내려간 지표는 음수로 나와야 화면이 아래 화살표를 그린다. final health = detail.scores.firstWhere((s) => s.metric == FpiMetric.health); expect(health.trend, -320); }); test('TOTAL FPI 는 서버 fpiScore 하나를 읽는다 — 갤럭시 HUD 와 같은 원천', () async { final db = await seedPlanet(); final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); final detail = await repo.load(nebula: false); // 가중치 초기 1.0 이므로 지금은 5축 합과 같다(별첨 B · 설계서 §4.1). expect(detail.totalFpi, 284310); expect(detail.totalFpi, detail.scores.fold(0, (a, s) => a + s.value)); // 가중치가 1.0 을 벗어나 서버 값이 축 합과 갈리면 **서버 값을 따른다**. // 갤럭시 HUD 가 읽는 값과 같아야 두 화면이 어긋나지 않는다. await db.doc('fandoms/bts') .set({'fpiScore': 300000}, SetOptions(merge: true)); expect((await repo.load(nebula: false)).totalFpi, 300000); }); test('서버가 내 기여 상위 % 를 적지 않은 축은 null 이다 — 「상위 100%」를 짓지 않는다', () async { final db = await seedPlanet(); await db.doc('fandoms/bts/members/$_uid') .set({'nickname': '나'}); final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') .load(nebula: false); expect(detail.scores.every((s) => s.myTopPercent == null), isTrue); }); test('행성 값이 0 인 축은 남아 있는 내 기여 상위 값을 쓰지 않는다', () async { // 10/4 실기기 — FPI 0 행성에 시드의 「상위 43%」가 그대로 떴다. final db = await seedPlanet(); await db.doc('fandoms/bts').set({ 'fpiAxes': {'bpm': 0}, }, SetOptions(merge: true)); final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') .load(nebula: false); final bpm = detail.scores.firstWhere((s) => s.metric == FpiMetric.bpm); expect(bpm.myTopPercent, isNull); // 값이 있는 축은 그대로 읽는다. final health = detail.scores.firstWhere((s) => s.metric == FpiMetric.health); expect(health.myTopPercent, 12); }); test('스냅샷이 없으면 동향은 0 이다', () async { final db = await seedPlanet(); await db.doc('fandoms/bts/fpiSnapshots/2026-09-01-00').delete(); final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') .load(nebula: false); expect(detail.scores.every((s) => s.trend == 0), isTrue); }); test('행성 랭킹은 지표별로 다른 쿼리를 쓴다 — 검토회신서 4절', () async { final db = await seedPlanet(); final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); final bpm = await repo.planetRanking(metric: FpiMetric.bpm); final contribution = await repo.planetRanking(metric: FpiMetric.contribution); expect(bpm.rows.first.name, 'NewJeans'); expect(contribution.rows.first.name, 'BTS'); // 지표 카드가 읽는 fpiAxes 와 같은 필드라 값이 갈릴 수 없다. final detail = await repo.load(nebula: false); final card = detail.scores.firstWhere((s) => s.metric == FpiMetric.bpm); expect(bpm.mine!.value, card.value); // **순위도 같은 원천이어야 한다.** 목록의 순번으로 다시 매기면 동점에서 // 서버 정렬과 문서 ID 정렬이 갈려 카드의 「전체 n위」와 어긋난다 — // 발주사 검토회신서 4절이 지적한 자리다. expect(bpm.mine!.rank, card.rank); // 순위 동향 = 직전 순위 − 현재 순위. BTS 는 3위에서 2위로 올랐다. expect(bpm.mine!.change, 1); }); test('내 행성이 상위 목록 밖이어도 소속 행성 영역은 뜬다 — Figma 546:14628', () async { final db = await seedPlanet(); final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); // 목록을 1칸으로 줄이면 BPM 2위인 BTS 는 목록 밖이다. final ranking = await repo.planetRanking(metric: FpiMetric.bpm, limit: 1); expect(ranking.rows.map((r) => r.name), ['NewJeans']); expect(ranking.mine, isNotNull); expect(ranking.mine!.rank, 2); expect(ranking.mine!.value, 82400); expect(ranking.mine!.change, 1); }); test('랭킹 모집단은 최근 7일 유효 활동 행성뿐이다 — 별첨 B', () async { final db = await seedPlanet(); final ranking = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') .planetRanking(metric: FpiMetric.bpm); expect(ranking.rows.map((r) => r.name), isNot(contains('조용한행성'))); }); test('사용자 랭킹도 지표별로 순서가 다르다', () async { final db = await seedPlanet(); final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); final bpm = await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'bts'); final referral = await repo.userRanking(metric: FpiMetric.referral, fandomId: 'bts'); expect(bpm.rows.first.name, '버니즈'); expect(referral.rows.first.name, '나'); expect(bpm.mine!.rank, 2); expect(referral.mine!.rank, 1); }); test('사용자 순위 · 순위 변동은 서버 5분 집계(rankByAxis · rankPrevByAxis)를 읽는다', () async { final db = await seedPlanet(); await db.doc('fandoms/bts/members/other').set({ 'rankByAxis': {'bpm': 1}, 'rankPrevByAxis': {'bpm': 2}, }, SetOptions(merge: true)); await db.doc('fandoms/bts/members/$_uid').set({ 'rankByAxis': {'bpm': 2}, 'rankPrevByAxis': {'bpm': 1}, }, SetOptions(merge: true)); final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); final bpm = await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'bts'); expect(bpm.rows.map((r) => (r.name, r.rank, r.change)).toList(), [ ('버니즈', 1, 1), ('나', 2, -1), ]); expect(bpm.mine!.change, -1); expect(bpm.mockNote, isNull); }); test('국가 랭킹은 사전 집계 1건을 읽고, 없으면 Mock 이라고 알린다', () async { final db = await seedPlanet(); final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); expect((await repo.countryRanking(metric: FpiMetric.bpm)).isMock, isTrue); // 서버는 국가 **코드**만 적는다. 표시명은 화면이 붙인다(countryName). await db.doc('rankings/country-bpm').set({ 'rows': [ {'code': 'JP', 'value': 1284000, 'change': 0}, {'code': 'KR', 'value': 921000, 'change': 1}, {'code': 'XX', 'value': 100, 'change': 0}, ], }); final ranking = await repo.countryRanking(metric: FpiMetric.bpm); expect(ranking.isMock, isFalse); expect(ranking.rows.first.name, '일본'); expect(ranking.rows.first.rank, 1); // 표에 없는 코드는 코드를 그대로 띄운다 — 「미지정」으로 뭉개면 다른 나라가 된다. expect(ranking.rows.last.name, 'XX'); // 「나의 랭킹 영역」은 국가별 필터에서도 뜬다 — 내 국가(KR) 행이다. expect(ranking.mine!.id, 'KR'); expect(ranking.mine!.rank, 2); }); test('라운지는 최신순 5개를 먼저 주고 이어서 다음 5개를 준다', () async { final db = await seedPlanet(); final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); final detail = await repo.load(nebula: false); expect(detail.posts, hasLength(5)); expect(detail.posts.first.title, '글 0'); final next = await repo.morePosts(offset: 5); expect(next, hasLength(3)); expect(next.first.title, '글 5'); // 커서로 넘기므로 앞 장과 겹치지 않는다. final titles = [...detail.posts, ...next].map((p) => p.title).toSet(); expect(titles, hasLength(8)); }); test('offset 0 은 처음부터 다시 읽는다', () async { final db = await seedPlanet(); final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); await repo.load(nebula: false); final again = await repo.morePosts(offset: 0); expect(again.first.title, '글 0'); }); test('다른 팬덤 글은 섞이지 않는다', () async { final db = await seedPlanet(); await db.collection('posts').add({ 'fandomId': 'nj', 'visibility': 'visible', 'title': '남의 팬덤 글', 'createdAt': DateTime(2026, 9, 2), }); final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') .load(nebula: false); expect(detail.posts.map((p) => p.title), isNot(contains('남의 팬덤 글'))); }); }); group('POP → 행성 반영 — 화면이 서버 값으로 움직인다', () { /// 여기 쓰는 숫자는 **서버 E2E 가 실제로 만든 값**이다 — /// `firebase/functions/test/quest_e2e.test.ts` 의 「출석으로 받은 POP 을 캠페인에 /// 쓰면 화면이 읽는 필드가 함께 움직인다」가 에뮬레이터에서 남긴 상태를 그대로 넣고, /// 화면 값으로 옮겨지는지만 본다. test('개인 기여 증가는 공동 Growth POP으로 오인하지 않는다', () async { final db = await _seed(); // 출석 보상 10 POP 이 들어온 지갑(`grantPop` 이 쓴 활동 잔액). await db.doc('users/$_uid/wallet/main').set({'activityBalance': 10}); // 기여 3,000 POP 을 캠페인에 쓴 뒤의 멤버 문서(`fn-pop-spend` 가 쓴 값). await db.doc('fandoms/bts/members/$_uid').set({'contributedPop': 3000}); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.popBalance, 10); expect(snapshot.growth.currentPop, 6270); expect(snapshot.growth.requiredPop, 10000); // 3,000 / 10,000 — 다음 단계까지의 진행도. expect(snapshot.growth.growthPercent, 63); }); test('다음 단계 문턱이 적재되지 않았으면 임의값을 만들지 않는다', () async { final db = await _seed(); await db.doc('planetStages/2').delete(); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.growth.requiredPop, isNull); expect(snapshot.growth.growthPercent, isNull); }); }); group('MY — Firestore 구현', () { test('누적 기여·등급·행성 기여를 서버가 쓰는 필드에서 읽는다', () async { final db = await _seed(); // `fn-pop-spend` 가 기여 차감과 같은 트랜잭션에서 쓰는 세 필드다(설계서 §3.6). await db.doc('users/$_uid').set({ 'uid': _uid, 'fandomId': 'bts', 'nickname': '아미보이', 'totalContributedPop': 24800, 'userExp': 500000, 'userLevel': 31, 'passportTier': 'GOLD', }); await db.doc('fandoms/bts/members/$_uid').set({'contributedPop': 24800}); final profile = await FirestoreMyRepository(_backend(db)).profile(); expect(profile.contributedPop, 24800); // 레벨은 서버 값을 읽는다. 앱이 기여 POP 으로 다시 매기지 않는다(C-18). expect(profile.levelLabel, 'Lv.31 BEAM'); expect(profile.userExp, 500000); expect(profile.passportTier, PassportTier.gold); expect(profile.planetTiers.single.contributedPop, 24800); expect(profile.balancePop, 12540); }); test('레벨 필드가 없으면 Lv.1 · SILVER 로 둔다', () async { final db = await _seed(); final profile = await FirestoreMyRepository(_backend(db)).profile(); expect(profile.contributedPop, 0); expect(profile.levelLabel, 'Lv.1 GLOW'); expect(profile.passportTier, PassportTier.silver); }); test('완료 퀘스트는 검증 활동 표식 수에서 무효 표식을 뺀 값이다', () async { final db = await _seed(); final col = db.collection('users/$_uid/verifiedCompletions'); await col.doc('q1_20261004').set({'questId': 'q1'}); await col.doc('q2_20261005').set({'questId': 'q2'}); await col.doc('q3_20261005').set({'questId': 'q3', 'voided': true}); final profile = await FirestoreMyRepository(_backend(db)).profile(); expect(profile.completedQuests, 2); }); }); group('FPI 공동 순위 (C-19)', () { test('실값 동률만 같은 순위이고 다음 순위는 건너뛴다', () { final ranked = rankWithTies<(String, num)>( const [('c', 5), ('b', 10), ('a', 10), ('d', 10.4)], (r) => r.$2, (r) => r.$1, ); // 10.4 는 표시가 10 이어도 실값이 커서 단독 1위, 동점 10 두 개는 2·2, 다음은 4. expect([for (final (r, rank) in ranked) '${r.$1}$rank'], ['d1', 'a2', 'b2', 'c4']); }); test('갤럭시 순위도 공동 순위이고 동점 내부는 fandomId 오름차순이다', () async { final db = await _seed(); await db.doc('fandoms/zz').set({'name': 'Z', 'fpiScore': 100}); await db.doc('fandoms/aa').set({'name': 'A', 'fpiScore': 100}); await db.doc('fandoms/mm').set({'name': 'M', 'fpiScore': 50}); final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); // 픽스처의 bts(284,310)가 1위다. final ranks = {for (final p in snapshot.planets) p.id: p.rank}; expect(ranks['aa'], 2); expect(ranks['zz'], 2); expect(ranks['mm'], 4); final ids = snapshot.planets.map((p) => p.id).toList(); expect(ids.indexOf('aa'), lessThan(ids.indexOf('zz'))); }); }); group('이메일·UID 비노출 (B-52 · G-07)', () { test('이메일 형태·빈 값은 중립 이름으로 바꾼다', () { expect(publicName('test123@naver.com'), '팬'); expect(publicName(' '), '팬'); expect(publicName(null), '팬'); expect(publicName('아미보이'), '아미보이'); }); test('문서에 싣는 이름은 언어와 무관한 「팬」 · 읽을 때는 지금 언어의 「팬」(M-7)', () { // 규칙 publicNameOf 가 「팬」과 대조하므로 번역된 대체값을 실으면 안 된다. expect(storedPublicName(null), storedFanName); expect(storedPublicName('me@x.com'), storedFanName); expect(storedPublicName(' 아미보이 '), '아미보이'); expect(publicName(storedFanName, 'Fan'), 'Fan'); }); test('닉네임이 없어도 이메일 앞부분을 이름으로 쓰지 않는다', () async { final db = await _seed(); final backend = Backend( firestore: db, auth: MockFirebaseAuth( signedIn: true, mockUser: MockUser(uid: _uid, email: 'test123@naver.com'), ), ); final profile = await FirestoreMyRepository(backend).profile(); expect(profile.nickname, '팬'); }); test('홈 기여자명에 이메일이 남아 있어도 가린다', () async { final db = await _seed(); await db.doc('fandoms/bts').set({ 'name': 'BTS', 'lastContributorName': 'test123@naver.com', 'lastContributedPop': 10, }, SetOptions(merge: true)); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.growth.contributorName, '팬'); }); }); group('캠페인 — Firestore 구현', () { test('공개 캠페인만 규칙 스키마 필드로 읽는다', () async { final db = await _seed(); await db.doc('campaigns/c1').set({ 'status': 'funding', 'creatorUid': 'u-other', 'creatorName': '다이브짱', 'title': '강남역 LED', 'targetPop': 10000, 'raisedPop': 9200, 'donorCount': 3, }); await db.doc('campaigns/c2').set({'status': 'draft', 'title': '초안'}); final cards = await FirestoreCampaignRepository(_backend(db)).load(); expect(cards.map((c) => c.id), ['c1']); expect(cards.single.achievedPercent, 92); expect(cards.single.authorName, '다이브짱'); }); test('좋아요는 likes/{uid} 에 저장되고 상세가 서버 개수와 내 상태를 읽는다', () async { final db = await _seed(); final repo = FirestoreCampaignRepository(_backend(db)); await db.doc('campaigns/c1').set({'status': 'funding', 'title': '강남역 LED'}); await db.doc('campaigns/c1/likes/u-other').set({'createdAt': DateTime(2026)}); await repo.like('c1', true); var detail = await repo.detail('c1'); expect(detail.card.likes, 2); expect(detail.liked, isTrue); await repo.like('c1', false); detail = await repo.detail('c1'); expect(detail.card.likes, 1); expect(detail.liked, isFalse); }); test('응답 유실 뒤 재시도는 같은 clientSeq · 성공 뒤 새 후원은 새 번호', () async { final backend = _FlakyBackend(await _seed()); final repo = FirestoreCampaignRepository(backend); await expectLater(repo.contribute('c1', 100), throwsA(isA())); await repo.contribute('c1', 100); await repo.contribute('c1', 100); final seqs = backend.calls.map((c) => c['clientSeq']).toList(); expect(seqs[0], seqs[1]); expect(seqs[2], isNot(seqs[1])); // 기여 POP 고정은 서버가 한다 — 앱은 재화 종류를 보내지 않는다. expect(backend.calls.every((c) => !c.containsKey('popType')), isTrue); }); test('기여 랭킹에 UID 를 이름으로 내보내지 않는다', () async { final db = await _seed(); await db.doc('campaigns/c1/donations/d1').set({'uid': 'u-other', 'amount': 300}); await db.doc('campaigns/c1/donations/d2').set({'uid': _uid, 'amount': 100}); final rows = await FirestoreCampaignRepository(_backend(db)).ranking('c1'); expect(rows.map((r) => r.name), ['팬', '나']); expect(rows.first.pop, 300); }); }); group('저장소 선택', () { test('서버가 없으면 표본을 쓴다', () { final repos = Repositories(live: false); expect(repos.home, isA()); expect(repos.galaxy, isA()); expect(repos.planet('bts'), isA()); expect(repos.quest, isA()); }); test('서버가 있으면 Firestore 구현을 쓴다', () async { final repos = Repositories(live: true, backend: _backend(await _seed())); expect(repos.home, isA()); expect(repos.galaxy, isA()); expect(repos.planet('bts'), isA()); expect(repos.quest, isA()); }); test('팬덤이 정해지지 않았으면 행성 상세는 표본으로 내려간다', () async { final repos = Repositories(live: true, backend: _backend(await _seed())); expect(repos.planet(null), isA()); expect(repos.planet(''), isA()); }); test('MY 와 알림함도 같은 갈림길을 지난다', () async { final live = Repositories(live: true, backend: _backend(await _seed())); expect(live.my, isA()); expect(live.notifications, isA()); final mock = Repositories(live: false); expect(mock.my, isA()); expect(mock.notifications, isA()); }); }); group('MY — Firestore 구현', () { /// 원장 시드. **차감은 음수다** — `spendPop` 이 `amount: -input.amount` 로 /// 쓰고 설계서 §3.4 대조식이 `balance == Σ amount` 이므로, 양수로 시드하면 /// 화면이 부호를 뒤집는 잘못이 테스트를 통과해 버린다. Future seedLedger() async { final db = await _seed(); // 같은 날 Zap 두 건 + 다른 거래 한 건 + 전날 Zap 한 건 + 지급 한 건. await db.doc('users/$_uid/popTransactions/z2').set({ 'type': 'spend', 'popType': 'general', 'amount': -10, 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 12540, 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 21)), }); await db.doc('users/$_uid/popTransactions/z1').set({ 'type': 'spend', 'popType': 'general', 'amount': -10, 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 12550, 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 20)), }); await db.doc('users/$_uid/popTransactions/fund').set({ 'type': 'spend', 'popType': 'contribution', 'amount': -500, 'reasonCode': 'CAMPAIGN_FUND', 'balanceAfter': 12560, 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 10)), }); await db.doc('users/$_uid/popTransactions/z0').set({ 'type': 'spend', 'popType': 'general', 'amount': -10, 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 13060, 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 9, 22)), }); await db.doc('users/$_uid/popTransactions/quest').set({ 'type': 'grant', 'popType': 'general', 'amount': 50, 'reasonCode': 'QUEST_STEPS', 'balanceAfter': 13070, 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 8, 9)), }); return db; } test('원장의 부호를 그대로 옮긴다 — 차감은 음수', () async { final db = await seedLedger(); final rows = await FirestoreMyRepository(_backend(db)).popHistory(); // 최신순이고 Zap 은 같은 날 한 줄로 접힌다. expect(rows.map((e) => e.title).toList(), [ 'Zap 기여', '캠페인 후원', 'Zap 기여', '만보기 퀘스트', ]); expect(rows.first.repeatCount, 2); expect(rows.first.delta, -20); // 접힌 줄의 누적 값은 그룹에서 가장 최신 행의 값이다. expect(rows.first.balanceAfter, 12540); expect(rows.first.kind, PopKind.general); expect(rows.first.txType, PopTxType.spend); final fund = rows[1]; expect(fund.kind, PopKind.contribution); expect(fund.delta, -500); expect(fund.displaySubtitle, '기여 · 차감 · 기여 POP'); // 날이 다른 Zap 은 접히지 않는다. expect(rows[2].repeatCount, 1); expect(rows[2].delta, -10); expect(rows.last.delta, 50); expect(rows.last.txType, PopTxType.grant); // 차감이 양수로 보이면 화면의 증감 부호가 뒤집힌다. 음수 하나라도 없으면 실패다. expect(rows.where((e) => e.delta < 0).length, 3); }); test('원장 합산이 서버 잔액을 재현한다 — 설계서 §3.4 대조식', () async { final db = await seedLedger(); final rows = await FirestoreMyRepository(_backend(db)).popHistory(); // 접기는 합계를 바꾸지 않는다. Zap 두 건을 한 줄로 접어도 총합은 같다. final sum = rows.fold(0, (a, e) => a + e.delta); expect(sum, -10 - 10 - 500 - 10 + 50); }); test('Zap 횟수는 원장의 count 를 합산한다', () async { final db = await _seed(); // 한 번에 여러 번 보낸 Zap. `fn-eclipse-defend` 가 `count` 를 묶어 받는다. await db.doc('users/$_uid/popTransactions/zc2').set({ 'type': 'spend', 'popType': 'general', 'amount': -50, 'count': 5, 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 12500, 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 21)), }); await db.doc('users/$_uid/popTransactions/zc1').set({ 'type': 'spend', 'popType': 'general', 'amount': -30, 'count': 3, 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 12550, 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 20)), }); // 예전 행에는 `count` 가 없다. 한 번으로 센다. await db.doc('users/$_uid/popTransactions/zc0').set({ 'type': 'spend', 'popType': 'general', 'amount': -10, 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 12580, 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 19)), }); final rows = await FirestoreMyRepository(_backend(db)).popHistory(); expect(rows.single.repeatCount, 9); expect(rows.single.displayTitle, 'Zap 기여 9회'); // 합산은 금액도 그대로 더한다. expect(rows.single.delta, -90); }); test('배포된 Functions 의 activity·support 도 같은 두 종류로 읽는다', () async { final db = await _seed(); await db.doc('users/$_uid/popTransactions/iap').set({ 'type': 'grant', 'popType': 'support', 'amount': 5500, 'reasonCode': 'IAP_PURCHASE', 'balanceAfter': 18040, 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 7)), }); final rows = await FirestoreMyRepository(_backend(db)).popHistory(); expect(rows.single.kind, PopKind.contribution); expect(rows.single.title, '상품 구매'); }); test('커서는 마지막 줄의 위치이고 그 다음만 온다', () async { final db = await seedLedger(); final repo = FirestoreMyRepository(_backend(db)); // 원장 두 건을 읽었는데 둘 다 같은 날 Zap 이라 한 줄로 접힌다. // 장의 길이가 읽은 건수와 다를 수 있는 이유이고, 화면이 길이로 끝을 // 판정하지 않는 근거다. final first = await repo.popHistory(limit: 2); expect(first.length, 1); expect(first.single.repeatCount, 2); // 커서는 표시 시각이 아니라 접힌 줄이 품은 가장 오래된 행의 위치다. expect(first.single.at, DateTime(2026, 9, 10, 21)); expect(first.single.cursor?.at, DateTime(2026, 9, 10, 20)); expect(first.single.cursor?.id, 'z1'); final next = await repo.popHistory(limit: 2, after: first.last.cursor); expect(next.map((e) => e.title).toList(), ['캠페인 후원', 'Zap 기여']); final last = await repo.popHistory(limit: 2, after: next.last.cursor); expect(last.map((e) => e.title).toList(), ['만보기 퀘스트']); // 빈 장이 오면 끝이다. expect(await repo.popHistory(limit: 2, after: last.last.cursor), isEmpty); }); test('createdAt 이 같은 두 행도 경계에서 빠지지 않는다', () async { final db = await _seed(); // 한 트랜잭션이 일반·기여를 각각 쓰면 서버 타임스탬프가 같아진다. final same = Timestamp.fromDate(DateTime(2026, 9, 10, 12)); await db.doc('users/$_uid/popTransactions/pair-a').set({ 'type': 'spend', 'popType': 'general', 'amount': -100, 'reasonCode': 'QNA_BOUNTY', 'balanceAfter': 900, 'createdAt': same, }); await db.doc('users/$_uid/popTransactions/pair-b').set({ 'type': 'spend', 'popType': 'contribution', 'amount': -50, 'reasonCode': 'QNA_BOUNTY', 'balanceAfter': 850, 'createdAt': same, }); final repo = FirestoreMyRepository(_backend(db)); final first = await repo.popHistory(limit: 1); expect(first.length, 1); final second = await repo.popHistory(limit: 1, after: first.last.cursor); // 시각만 비교했다면 같은 시각의 남은 한 행이 통째로 사라진다. expect(second.length, 1); expect(second.single.delta, isNot(first.single.delta)); expect(await repo.popHistory(limit: 1, after: second.last.cursor), isEmpty); }); test('공지는 게시된 것만 최신순으로 읽는다', () async { final db = await _seed(); await db.doc('notices/n1').set({ 'active': true, 'title': '서비스 점검 안내', 'body': '점검이 있습니다.', 'publishedAt': Timestamp.fromDate(DateTime(2026, 9, 1)), }); await db.doc('notices/n2').set({ 'active': true, 'title': '이클립스 방어 이벤트', 'body': '추가 POP 을 드립니다.', 'publishedAt': Timestamp.fromDate(DateTime(2026, 9, 5)), }); // 내려 둔 공지는 목록에 오르지 않는다. await db.doc('notices/n3').set({ 'active': false, 'title': '지난 이벤트', 'body': '끝났습니다.', 'publishedAt': Timestamp.fromDate(DateTime(2026, 9, 6)), }); final notices = await FirestoreMyRepository(_backend(db)).notices(); expect(notices.map((n) => n.title).toList(), [ '이클립스 방어 이벤트', '서비스 점검 안내', ]); expect(notices.first.at, DateTime(2026, 9, 5)); }); test('차단을 추가하고 목록으로 읽고 해제한다', () async { final db = await _seed(); final repo = FirestoreMyRepository(_backend(db)); await repo.block('u-spam', nickname: '스팸계정'); var blocked = await repo.blockedUsers(); expect(blocked.single.uid, 'u-spam'); expect(blocked.single.nickname, '스팸계정'); await repo.unblock('u-spam'); blocked = await repo.blockedUsers(); expect(blocked, isEmpty); }); test('자기 자신은 차단하지 않는다', () async { final repo = FirestoreMyRepository(_backend(await _seed())); expect(() => repo.block(_uid), throwsA(isA())); }); test('문의를 접수 상태로 넣고 내 것만 읽는다', () async { final db = await _seed(); final repo = FirestoreMyRepository(_backend(db)); await repo.submitInquiry('결제 문의', '지급되지 않았습니다.'); await db.collection('inquiries').add({ 'authorUid': 'someone-else', 'title': '남의 문의', 'status': 'pending', 'createdAt': Timestamp.now(), }); final mine = await repo.myInquiries(); expect(mine.single.title, '결제 문의'); expect(mine.single.status, InquiryStatus.pending); }); test('운영이 답변하면 상태와 답변이 함께 올라온다', () async { final db = await _seed(); await db.collection('inquiries').add({ 'authorUid': _uid, 'title': '환불 문의', 'body': '환불이 되지 않습니다.', 'status': 'answered', 'answerBody': '처리했습니다.', 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 1)), 'answeredAt': Timestamp.fromDate(DateTime(2026, 9, 2)), }); final mine = await FirestoreMyRepository(_backend(db)).myInquiries(); expect(mine.single.status, InquiryStatus.answered); expect(mine.single.answerBody, '처리했습니다.'); expect(mine.single.answeredAt, DateTime(2026, 9, 2)); }); test('프로필은 사진 URL 을 함께 싣는다', () async { final db = await _seed(); await db.doc('users/$_uid').set({ 'nickname': '아미보이', 'photoUrl': 'https://example.invalid/photo.png', }, SetOptions(merge: true)); final profile = await FirestoreMyRepository(_backend(db)).profile(); expect(profile.nickname, '아미보이'); expect(profile.photoUrl, 'https://example.invalid/photo.png'); }); }); group('알림함 — Firestore 구현', () { test('최신순으로 읽고 읽음 상태를 옮긴다', () async { final db = await _seed(); await db.doc('users/$_uid/notifications/n1').set({ 'type': 'fandomEvent', 'eventType': 'quest', 'title': '만보기 퀘스트를 완료했습니다', 'read': false, 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 9)), }); await db.doc('users/$_uid/notifications/n2').set({ 'type': 'fandomEvent', 'eventType': 'eclipse', 'title': '이클립스가 발생했습니다', 'read': true, 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 11, 9)), }); final repo = FirestoreNotificationRepository(_backend(db)); final items = await repo.load(); expect(items.map((i) => i.id).toList(), ['n2', 'n1']); expect(items.first.kind, NotificationKind.eclipse); expect(items.first.read, isTrue); expect(items.last.kind, NotificationKind.quest); await repo.markRead('n1'); final after = (await db.doc('users/$_uid/notifications/n1').get()).data(); expect(after?['read'], isTrue); expect(after?['readAt'], isNotNull); }); test('같은 카테고리 안에서도 이동 규칙이 갈린다 — 두 축이 필요한 이유', () async { final db = await _seed(); // 정책 원장 B-77 의 「팬덤 이벤트」 하나가 이클립스·캠페인·퀘스트를 모두 // 담는데, Figma `518:88353` 은 이클립스만 이동하지 말라고 갈라 두었다. for (final (id, eventType) in [ ('a', 'eclipse'), ('b', 'campaign'), ('c', 'quest'), ]) { await db.doc('users/$_uid/notifications/$id').set({ 'type': 'fandomEvent', 'eventType': eventType, 'title': '알림 $id', 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 12)), }); } final items = await FirestoreNotificationRepository(_backend(db)).load(); final byId = {for (final i in items) i.id: i}; expect(byId['a']!.kind.navigable, isFalse); expect(byId['b']!.kind.target, '캠페인'); expect(byId['c']!.kind.target, '퀘스트 상세'); }); test('eventType 이 없으면 type 으로 물러나고 이동하지 않는다', () async { final db = await _seed(); // 수신 설정 축의 값만 실려 온 문서. 어디로 보낼지 알 수 없으므로 보내지 않는다. await db.doc('users/$_uid/notifications/x').set({ 'type': 'marketing', 'title': '이벤트 안내', 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 12)), }); final items = await FirestoreNotificationRepository(_backend(db)).load(); expect(items.single.kind.navigable, isFalse); expect(items.single.title, '이벤트 안내'); }); test('모르는 종류도 이동하지 않는 쪽으로 접는다', () async { final db = await _seed(); await db.doc('users/$_uid/notifications/y').set({ 'type': 'essential', 'eventType': '미래에_생길_종류', 'title': '무언가 일어났습니다', 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 12)), }); final items = await FirestoreNotificationRepository(_backend(db)).load(); expect(items.single.kind.navigable, isFalse); expect(items.single.title, '무언가 일어났습니다'); }); }); group('AI Daily Briefing — 당일 게시분만 (정책 원장 B-47)', () { /// 브리핑 한 줄을 심는다. `fn-briefing-ingest` 가 쓰는 필드를 그대로 쓴다. /// B-75 5 요소를 기본으로 채우고, 누락 검증만 [drop] 으로 하나를 뺀다. Future brief( FakeFirebaseFirestore db, { required String dayKey, required int rank, required String status, required String headline, String section = 'News', String? drop, }) { final data = { 'fandomId': 'bts', 'dayKey': dayKey, 'section': section, 'rank': rank, 'status': status, 'headline': headline, 'sourceName': '연합뉴스', 'sourceAt': Timestamp.fromDate(DateTime(2026, 9, 14, 8)), 'sourceUrl': 'https://example.com/$headline', 'articleUrl': 'https://example.com/$headline', 'aiSummary': '요약입니다.', 'aiGenerated': true, }; if (drop != null) data.remove(drop); return db.doc('briefings/bts_${dayKey}_${section}_$rank').set(data); } String today() => kstDayKey(); String yesterday() => kstDayKey(DateTime.now().toUtc().subtract(const Duration(days: 1))); test('게시된 당일분을 rank 순으로 읽고 5 요소를 담아 온다', () async { final db = await _seed(); await brief(db, dayKey: today(), rank: 2, status: 'published', headline: '둘째 줄'); await brief(db, dayKey: today(), rank: 1, status: 'published', headline: '첫째 줄'); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.briefings.map((b) => b.headline), ['첫째 줄', '둘째 줄']); expect(snapshot.briefings.map((b) => b.rank), [1, 2]); // B-75 — 노출 항목 전건에 5 요소가 있어야 한다. final first = snapshot.briefings.first; expect(first.section, BriefingSection.news); expect(first.sourceName, '연합뉴스'); expect(first.sourceUrl, isNotEmpty); expect(first.articleUrl, isNotEmpty); expect(first.aiSummary, isNotEmpty); expect(first.sourceAt.year, 2026); }); test('5 요소가 빠진 문서는 버린다 — 출처 없는 카드를 그리지 않는다', () async { for (final missing in [ 'sourceName', 'sourceAt', 'sourceUrl', 'articleUrl', 'aiSummary', 'section', ]) { final db = await _seed(); await brief(db, dayKey: today(), rank: 1, status: 'published', headline: '메타가 빈 줄', drop: missing); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.briefings, isEmpty, reason: missing); } }); test('정책이 정한 News·SNS 밖의 섹션은 버린다', () async { final db = await _seed(); await brief(db, dayKey: today(), rank: 1, status: 'published', headline: '근거 없는 구획', section: 'activity'); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.briefings, isEmpty); }); test('News 와 SNS 를 구획 그대로 읽어 온다', () async { final db = await _seed(); await brief(db, dayKey: today(), rank: 1, status: 'published', headline: '뉴스 줄'); await brief(db, dayKey: today(), rank: 1, status: 'published', headline: 'SNS 줄', section: 'SNS'); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect( snapshot.briefings.map((b) => b.section).toSet(), {BriefingSection.news, BriefingSection.sns}, ); }); test('미승인 Draft 는 노출 0 건이다', () async { final db = await _seed(); await brief(db, dayKey: today(), rank: 1, status: 'draft', headline: '초안'); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.briefings, isEmpty); }); test('전일 게시분은 재사용하지 않는다', () async { final db = await _seed(); await brief(db, dayKey: yesterday(), rank: 1, status: 'published', headline: '어제 줄'); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.briefings, isEmpty); }); test('0 건이면 빈 목록이라 홈이 블록을 숨긴다', () async { final db = await _seed(); final snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.briefings, isEmpty); }); test('KST 로 날짜를 끊는다 — UTC 로 끊으면 오전 9 시에 하루가 바뀐다', () { expect(kstDayKey(DateTime.utc(2026, 9, 13, 16)), '2026-09-14'); expect(kstDayKey(DateTime.utc(2026, 9, 13, 14, 59)), '2026-09-13'); }); }); group('푸시 설정 — Firestore (정책 원장 B-77)', () { test('문서가 비면 정책 기본값을 쓴다 — Marketing 만 OFF', () async { final db = await _seed(); final prefs = await FirestoreMyRepository(_backend(db)).pushSettings(); expect(prefs[PushTopic.marketing], isFalse); expect(prefs[PushTopic.briefing], isTrue); expect(prefs.length, 5); }); test('끈 구분만 notificationPrefs 에 남는다', () async { final db = await _seed(); final repo = FirestoreMyRepository(_backend(db)); await repo.setPush(PushTopic.briefing, false); final saved = (await db.doc('users/$_uid').get()).data()!; expect((saved['notificationPrefs'] as Map)['briefing'], isFalse); expect(await repo.pushSettings(), containsPair(PushTopic.briefing, false)); }); test('서비스 필수는 저장하지 않는다 — 끌 수 없는 구분이다', () async { final db = await _seed(); final repo = FirestoreMyRepository(_backend(db)); await repo.setPush(PushTopic.essential, false); final saved = (await db.doc('users/$_uid').get()).data()!; expect(saved['notificationPrefs'], isNull); expect(await repo.pushSettings(), containsPair(PushTopic.essential, true)); }); }); group('푸시 토픽 계획', () { test('초기값에서는 Marketing 만 해지 대상이다', () { final plan = topicPlan('bts', PushTopic.defaults); expect(plan.subscribe, contains('fandom_bts_briefing')); expect(plan.subscribe, isNot(contains('fandom_bts_marketing'))); expect(plan.unsubscribe, {'fandom_bts_marketing'}); }); test('모든 구분이 구독 아니면 해지 한쪽에 들어간다', () { final plan = topicPlan('bts', PushTopic.defaults); expect( plan.subscribe.length + plan.unsubscribe.length, PushTopic.values.length, ); expect(plan.subscribe.intersection(plan.unsubscribe), isEmpty); }); test('구분을 끄면 해지 대상이 된다', () { final plan = topicPlan( 'bts', {...PushTopic.defaults, PushTopic.briefing: false}, ); expect(plan.unsubscribe, contains('fandom_bts_briefing')); expect(plan.subscribe, isNot(contains('fandom_bts_briefing'))); }); test('서비스 필수는 설정을 꺼도 구독을 유지한다', () { final plan = topicPlan( 'bts', {...PushTopic.defaults, PushTopic.essential: false}, ); expect(plan.subscribe, contains('fandom_bts_essential')); expect(plan.unsubscribe, isNot(contains('fandom_bts_essential'))); }); test('FCM 이 받지 않는 문자를 서버와 같게 치환한다', () { // 서버 `firebase/functions/src/notify/push.ts` 의 `fandomTopic` 과 같은 규약. // 한쪽만 치환하면 보내는 토픽과 구독한 토픽이 갈린다. expect( PushTopic.briefing.topicFor('b t/s'), 'fandom_b-t-s_briefing', ); expect(PushTopic.briefing.topicFor('bts'), 'fandom_bts_briefing'); }); }); group('푸시 구독 갱신 — 설정이 실제 구독을 바꾼다', () { /// 팬덤 소속이 있는 사용자로 [PushMessaging] 을 세운다. Future<({PushMessaging push, _FakeTransport transport})> boot() async { final db = await _seed(); final transport = _FakeTransport(); return ( push: PushMessaging(backend: _backend(db), transport: transport), transport: transport, ); } test('AI Briefing 을 끄면 그 토픽을 해지한다 — OFF 발송 0 건', () async { final (push: push, transport: transport) = await boot(); await push.syncTopics({ ...PushTopic.defaults, PushTopic.briefing: false, }); expect(transport.unsubscribed, contains('fandom_bts_briefing')); expect(transport.subscribed, isNot(contains('fandom_bts_briefing'))); }); test('Marketing 을 켜면 그 토픽을 구독한다', () async { final (push: push, transport: transport) = await boot(); await push.syncTopics({ ...PushTopic.defaults, PushTopic.marketing: true, }); expect(transport.subscribed, contains('fandom_bts_marketing')); }); test('앱이 기억한 상태를 믿지 않고 매번 전건을 맞춘다', () async { final (push: push, transport: transport) = await boot(); await push.syncTopics(PushTopic.defaults); await push.syncTopics(PushTopic.defaults); // FCM 두 호출은 멱등이다. 두 번 불러야 재설치·토큰 재발급으로 실제 구독이 // 달라진 경우를 되돌릴 수 있다. expect( transport.subscribed.where((t) => t == 'fandom_bts_briefing').length, 2, ); }); test('로그아웃 정리는 전 구분을 해지하고 토큰을 지운다', () async { final db = await _seed(); final transport = _FakeTransport(); final push = PushMessaging(backend: _backend(db), transport: transport); await push.syncToken(); expect( (await db.doc('users/$_uid').get()).data()!['fcmTokens'], contains('fake-token'), ); await push.clearOnSignOut(); for (final topic in PushTopic.values) { expect(transport.unsubscribed, contains(topic.topicFor('bts'))); } expect( (await db.doc('users/$_uid').get()).data()!['fcmTokens'], isEmpty, ); }); test('팬덤 소속이 없으면 아무 토픽도 건드리지 않는다', () async { final db = await _seed(); await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': ''}); final transport = _FakeTransport(); final push = PushMessaging(backend: _backend(db), transport: transport); await push.syncTopics(PushTopic.defaults); expect(transport.subscribed, isEmpty); expect(transport.unsubscribed, isEmpty); }); }); group('푸시 수신 분기', () { test('이클립스는 갤럭시로, 브리핑은 홈으로 보낸다', () { const eclipse = PushNotice(title: 't', body: 'b', kind: 'eclipse'); const briefing = PushNotice(title: 't', body: 'b', kind: 'briefing'); const unknown = PushNotice(title: 't', body: 'b', kind: ''); expect(eclipse.route, '/galaxy'); expect(briefing.route, '/home'); expect(unknown.route, '/home'); }); test('문구가 없는 데이터 전용 메시지는 화면에 띄우지 않는다', () { const empty = PushNotice(title: '', body: '', kind: 'briefing'); expect(empty.hasText, isFalse); }); }); } /// FCM 플러그인 대역. 구독·해지 호출을 순서대로 기록한다. /// /// 실제 `FirebaseMessaging` 은 비공개 생성자라 테스트에서 세울 수 없다. 그래서 /// [PushTransport] 경계를 두었고, 이 클래스가 그 경계의 유일한 대역이다. final class _FakeTransport implements PushTransport { final List subscribed = []; final List unsubscribed = []; @override Future requestPermission() async => AuthorizationStatus.authorized; @override Future currentStatus() async => AuthorizationStatus.authorized; @override Future getToken() async => 'fake-token'; @override Stream get onTokenRefresh => const Stream.empty(); @override Future subscribeToTopic(String topic) async => subscribed.add(topic); @override Future unsubscribeFromTopic(String topic) async => unsubscribed.add(topic); @override Future getInitialMessage() async => null; }