Files
fanuzu/app/test/backend_repository_test.dart
T

1579 lines
71 KiB
Dart

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<Map<String, dynamic>> calls = [];
@override
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> payload = const {},
]) async {
calls.add(payload);
if (calls.length == 1) throw const BackendException('E-SYS-01', '응답 유실');
return const {};
}
}
Future<FakeFirebaseFirestore> _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('서버가 Level 판정을 적은 행성은 레벨업 POP 진행도와 다음 Stage 진입 누계로 그린다 (수치표 v1.4)', () async {
final db = await _seed();
// 원장 누계 growthPop 이 아니라 기준점 이후 · 가중치 반영 진행도(levelPop)를 쓴다.
await db.doc('fandoms/bts').update({
'level': 20, 'levelPop': 105000.5, 'nextStagePop': 110500, 'nextStageSfpi': 16575,
});
final home = await FirestoreHomeRepository(_backend(db)).load();
expect(home.growth.currentPop, 105000.5);
expect(home.growth.requiredPop, 110500);
expect(home.growth.requiredSfpi, 16575);
});
test('갤럭시 검색은 맵 상위 12개 밖의 행성도 이름 색인으로 찾는다 (Figma 353:217832)', () async {
final db = await _seed();
for (var i = 0; i < 15; i++) {
await db.doc('fandoms/f$i').set({'name': 'Star$i', 'stage': 1, 'fpiScore': 1000 - i});
await db.doc('fandomNames/star$i').set({'fandomId': 'f$i'});
}
await db.doc('fandoms/low').set({'name': 'Moonlight', 'stage': 1, 'fpiScore': 1});
await db.doc('fandomNames/moonlight').set({'fandomId': 'low'});
final repo = FirestoreGalaxyRepository(_backend(db));
final map = await repo.load();
expect(map.planets.any((p) => p.id == 'low'), isFalse);
final hits = await repo.search('MOON');
expect(hits.single.name, 'Moonlight');
expect((await repo.search('star1')).map((p) => p.id).toSet(),
{'f1', 'f10', 'f11', 'f12', 'f13', 'f14'});
expect(await repo.search(' '), isEmpty);
});
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});
// 성운 전환 목표는 앱 상수가 아니라 서버와 같은 운영값(B-10).
await db.doc('config/policy').set({'nebulaMinPop': 2000, 'nebulaMinMembers': 12});
final nebula = await FirestorePlanetRepository(_backend(db), fandomId: 'bts')
.load(nebula: false);
expect(nebula.stage, 0);
expect(nebula.isNebula, true);
expect([nebula.nebulaTargetPop, nebula.nebulaTargetMembers], [2000, 12]);
});
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');
// 설명 프레임 `2097:16391` — 인사 줄은 로그인 사용자 기준. 팬덤의 직전 기여자가 아니다.
expect(snapshot.growth.contributorName, isNot('BTS_Hong'));
expect(snapshot.growth.contributedPop, 0);
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<FakeFirebaseFirestore> 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<int>(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('랭킹은 마지막 갱신 시각과 갱신 주기를 함께 준다 — 별첨 1 ② 예외처리', () async {
final db = await seedPlanet();
final at = DateTime.utc(2026, 10, 7, 3, 4);
await db.doc('fandoms/bts').update({
'fpiUpdatedAt': Timestamp.fromDate(at),
'memberRankedAt': Timestamp.fromDate(at),
});
final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts');
final planets = await repo.planetRanking(metric: FpiMetric.bpm);
expect([planets.updatedAt, planets.refreshMinutes], [at.toLocal(), 1]);
final users = await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'bts');
expect([users.updatedAt, users.refreshMinutes], [at.toLocal(), 5]);
});
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,
// 사용자 문서의 단일 Tier 는 읽지 않는다 — Passport 는 팬덤별 문서다(10/3 #10).
'passportTier': 'DIAMOND',
});
await db.doc('fandoms/bts/members/$_uid').set({'contributedPop': 24800});
await db.doc('users/$_uid/passports/bts').set({'tier': 'GOLD'});
// 탈퇴한 팬덤의 Passport 도 보존 기록으로 보인다(9/21 5절).
await db.doc('fandoms/left').set({'name': '떠난팬덤'});
await db.doc('users/$_uid/passports/left').set({'tier': 'DIAMOND', 'contributedPop': 700});
await db.doc('users/$_uid/badges/activity_health').set({'kind': 'activity', 'axis': 'health', 'stage': 2});
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);
final bts = profile.planetTiers.singleWhere((t) => t.fandomId == 'bts');
expect(bts.contributedPop, 24800);
expect(bts.isPrimary, isTrue);
final left = profile.planetTiers.singleWhere((t) => t.fandomId == 'left');
expect([left.joined, left.passportTier, left.contributedPop, left.fandomName],
[false, PassportTier.diamond, 700, '떠난팬덤']);
expect(profile.badges.single.label, 'HEALTH · 2/7');
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('users/$_uid').set({
'nickname': 'ARMY_Kim', 'photoUrl': 'https://example.com/p.png',
}, SetOptions(merge: true));
var snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.growth.contributorName, 'ARMY_Kim');
expect(snapshot.growth.photoUrl, 'https://example.com/p.png');
await db.doc('users/$_uid').set({
'nickname': 'test123@naver.com', 'photoUrl': '',
}, SetOptions(merge: true));
snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.growth.contributorName, '팬');
expect(snapshot.growth.photoUrl, isNull);
});
});
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<BackendException>()));
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<SampleHomeRepository>());
expect(repos.galaxy, isA<SampleGalaxyRepository>());
expect(repos.planet('bts'), isA<SamplePlanetRepository>());
expect(repos.quest, isA<SampleQuestRepository>());
});
test('서버가 있으면 Firestore 구현을 쓴다', () async {
final repos = Repositories(live: true, backend: _backend(await _seed()));
expect(repos.home, isA<FirestoreHomeRepository>());
expect(repos.galaxy, isA<FirestoreGalaxyRepository>());
expect(repos.planet('bts'), isA<FirestorePlanetRepository>());
expect(repos.quest, isA<FirestoreQuestRepository>());
});
test('팬덤이 정해지지 않았으면 행성 상세는 표본으로 내려간다', () async {
final repos = Repositories(live: true, backend: _backend(await _seed()));
expect(repos.planet(null), isA<SamplePlanetRepository>());
expect(repos.planet(''), isA<SamplePlanetRepository>());
});
test('MY 와 알림함도 같은 갈림길을 지난다', () async {
final live = Repositories(live: true, backend: _backend(await _seed()));
expect(live.my, isA<FirestoreMyRepository>());
expect(live.notifications, isA<FirestoreNotificationRepository>());
final mock = Repositories(live: false);
expect(mock.my, isA<SampleMyRepository>());
expect(mock.notifications, isA<SampleNotificationRepository>());
});
});
group('MY — Firestore 구현', () {
/// 원장 시드. **차감은 음수다** — `spendPop` 이 `amount: -input.amount` 로
/// 쓰고 설계서 §3.4 대조식이 `balance == Σ amount` 이므로, 양수로 시드하면
/// 화면이 부호를 뒤집는 잘못이 테스트를 통과해 버린다.
Future<FakeFirebaseFirestore> 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<int>(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<BackendException>()));
});
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<void> brief(
FakeFirebaseFirestore db, {
required String dayKey,
required int rank,
required String status,
required String headline,
String section = 'News',
String? drop,
}) {
final data = <String, dynamic>{
'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('가입한 팬덤 전부의 토픽을 맞춘다 — 대표 팬덤 하나만이 아니다(C-34)', () async {
final db = await _seed();
await db.doc('users/$_uid/memberships/nj').set({'fandomId': 'nj'});
final transport = _FakeTransport();
await PushMessaging(
backend: _backend(db),
transport: transport,
).syncTopics(PushTopic.defaults);
expect(
transport.subscribed,
containsAll(['fandom_bts_briefing', 'fandom_nj_briefing']),
);
});
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<String> subscribed = [];
final List<String> unsubscribed = [];
@override
Future<AuthorizationStatus> requestPermission() async =>
AuthorizationStatus.authorized;
@override
Future<AuthorizationStatus> currentStatus() async =>
AuthorizationStatus.authorized;
@override
Future<String?> getToken() async => 'fake-token';
@override
Stream<String> get onTokenRefresh => const Stream<String>.empty();
@override
Future<void> subscribeToTopic(String topic) async => subscribed.add(topic);
@override
Future<void> unsubscribeFromTopic(String topic) async =>
unsubscribed.add(topic);
@override
Future<RemoteMessage?> getInitialMessage() async => null;
}