Files
fanuzu/app/test/quest_repository_test.dart
T

515 lines
18 KiB
Dart

import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_evidence.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';
import 'package:shared_preferences/shared_preferences.dart';
/// 퀘스트 서버 연동 계층 — 화면 대조 원장 F4 의 데이터 경로.
///
/// **무엇을 읽어 무엇으로 옮기는지**와 **어느 함수를 부르는지**를 본다. 규칙이 틀리면
/// 화면은 조용히 0 을 그리거나 엉뚱한 함수를 불러 지급이 일어나므로, 눈으로는 잡히지
/// 않는다. 지급 자체의 판정은 서버 쪽 `firebase/functions/test/quest_e2e.test.ts` 가 본다.
const _uid = 'u-quest';
/// Cloud Functions 호출을 기록하는 경계. 실제 함수는 에뮬레이터에서 따로 검증한다.
class _RecordingBackend extends Backend {
_RecordingBackend(FakeFirebaseFirestore db, {Map<String, dynamic>? reply})
: _reply =
reply ?? const {'txId': 'tx-1', 'balanceAfter': 60, 'rewarded': 10},
super(
firestore: db,
auth: MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: _uid, displayName: '테스터'),
),
);
final Map<String, dynamic> _reply;
final List<({String name, Map<String, dynamic> payload})> calls = [];
@override
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> payload = const {},
]) async {
calls.add((name: name, payload: payload));
return _reply;
}
}
String _today() => DateTime.now()
.toUtc()
.add(const Duration(hours: 9))
.toIso8601String()
.substring(0, 10);
Future<FakeFirebaseFirestore> _seed() async {
final db = FakeFirebaseFirestore();
await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts'});
// 걸음 목표는 운영값이다 — 앱에 하드코딩하지 않는다(별첨 B 「운영값 관리 원칙」).
await db.doc('config/policy').set({
'stepsGoal': 10000,
'stepsPop': 50,
'attendPop': 10,
});
await db.doc('quests/attend').set({
'type': 'attend',
'title': '오늘의 출석',
'rewardPop': 50,
'dailyLimit': 1,
});
await db.doc('quests/steps').set({
'type': 'walk',
'title': '만보기',
'rewardPop': 50,
'dailyLimit': 1,
});
await db.doc('quests/place').set({
'type': 'location',
'title': '팝업 방문',
'rewardPop': 100,
'dailyLimit': 1,
'placeId': 'hongdae',
});
await db.doc('quests/stream').set({
'type': 'streaming',
'title': '신곡 완청',
'rewardPop': 10,
'dailyLimit': 1,
});
return db;
}
QuestCard _card(List<QuestCard> cards, String id) =>
cards.firstWhere((c) => c.id == id);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
// 걸음 기준선은 단말에 남는다. 회차마다 빈 저장소에서 시작해야 값이 겹치지 않는다.
SharedPreferences.setMockInitialValues({});
});
group('퀘스트 목록 — Firestore 구현', () {
test('UGC 퀘스트의 제출 건 조회가 거부돼도 목록 전체는 뜬다 (2026-10-04 실기기)', () async {
// 제출 건 읽기를 막은 규칙 — 배포 전 규칙에서 없는 문서 get 이 permission-denied 였다.
final db = FakeFirebaseFirestore(securityRules: '''
service cloud.firestore {
match /databases/{database}/documents {
match /quests/{id} { allow read, write: if true; }
match /config/{id} { allow read, write: if true; }
match /users/{document=**} { allow read, write: if true; }
}
}''');
await db.doc('quests/attend').set({'type': 'attend', 'title': '출석', 'dailyLimit': 1});
await db.doc('quests/ugc').set({
'type': 'community', 'title': '내가 만든 퀘스트', 'authorUid': 'someone',
'status': 'published', 'goal': 1,
});
final cards = await FirestoreQuestRepository(_RecordingBackend(db)).load();
expect(cards.map((c) => c.id).toSet(), {'attend', 'ugc'});
expect(_card(cards, 'ugc').stage, QuestStage.notStarted);
});
test('정의 문서를 카드로 옮기고 유형을 별칭까지 읽는다', () async {
final db = await _seed();
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load();
expect(cards.map((c) => c.id).toSet(), {
'attend',
'steps',
'place',
'stream',
});
expect(_card(cards, 'steps').type, QuestType.steps);
expect(_card(cards, 'place').type, QuestType.place);
// 출석은 문서의 50 이 아니라 서버가 지급하는 config/policy.attendPop 이다.
expect(_card(cards, 'attend').rewardPop, 10);
// 걸음 목표는 `config/policy` 에서 온다. 나머지 유형은 일일 한도가 목표다.
expect(_card(cards, 'steps').goal, 10000);
expect(_card(cards, 'attend').goal, 1);
expect(_card(cards, 'attend').stage, QuestStage.notStarted);
});
test('당일 한도를 채우면 보상완료로 본다', () async {
final db = await _seed();
await db.doc('users/$_uid/questProgress/attend').set({
'count': 1,
'dayKey': _today(),
});
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load();
expect(_card(cards, 'attend').stage, QuestStage.rewarded);
});
test('어제 진행분은 오늘로 세지 않는다', () async {
final db = await _seed();
await db.doc('users/$_uid/questProgress/attend').set({
'count': 1,
'dayKey': '2026-01-01',
});
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load();
expect(_card(cards, 'attend').stage, QuestStage.notStarted);
});
test('닫힌 퀘스트는 감추고 기간 필드가 없는 문서는 그대로 보여 준다', () async {
final db = await _seed();
await db.doc('quests/closed').set({
'type': 'attend',
'title': '지난 이벤트',
'rewardPop': 10,
'active': false,
});
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load();
expect(cards.map((c) => c.id), isNot(contains('closed')));
// 시연 데이터에도 Admin 문서에도 없는 필드를 근거로 감추지 않는다.
expect(cards.map((c) => c.id), contains('attend'));
});
test('내가 만든 퀘스트는 내 퀘스트 상태로 온다', () async {
final db = await _seed();
await db.doc('quests/mine').set({
'type': 'attend',
'title': '내가 만든 것',
'rewardPop': 10,
'authorUid': _uid,
});
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load();
expect(_card(cards, 'mine').stage, QuestStage.mine);
expect(_card(cards, 'mine').author, QuestAuthor.user);
});
});
group('퀘스트 참여 — 지급은 전부 Cloud Functions 를 지난다', () {
test('출석은 fnQuestAttend 를 부르고 서버 값을 그대로 옮긴다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
final cards = await repo.load();
final reward = await repo.verify(_card(cards, 'attend'));
expect(backend.calls.single.name, 'fnQuestAttend');
expect(reward.rewardedPop, 10);
expect(reward.balanceAfter, 60);
});
test('걸음은 하루 기준선 대비 오늘치를 보낸다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(
backend,
// 부팅 이후 누적값이 들어온다. 그대로 보내면 며칠 켜 둔 단말이 공짜로 통과한다.
stepCount: () => Stream.fromIterable([40_000, 45_000, 50_400]),
);
final cards = await repo.load();
final card = _card(cards, 'steps');
expect(await repo.progressStream(card).toList(), [0, 5_000, 10_400]);
await repo.verify(card);
expect(backend.calls.single.name, 'fnQuestVerifySteps');
expect(backend.calls.single.payload['steps'], 10_400);
// 진행 문서와 일일 한도를 퀘스트 단위로 세도록 문서 ID 를 함께 보낸다.
expect(backend.calls.single.payload['questId'], 'steps');
// 집계 축은 소속 팬덤이다 — `activityLogs.fandomId`(설계서 §2.1).
expect(backend.calls.single.payload['fandomId'], 'bts');
expect(backend.calls.single.payload['occurredAt'], isA<String>());
});
test('앱을 껐다 켜도 오늘치가 0 으로 돌아가지 않는다', () async {
final db = await _seed();
// 첫 실행 — 기준선 40,000 을 잡고 5,000 걷는다.
final first = FirestoreQuestRepository(
_RecordingBackend(db),
stepCount: () => Stream.fromIterable([40_000, 45_000]),
);
final card = _card(await first.load(), 'steps');
expect(await first.progressStream(card).toList(), [0, 5_000]);
// 앱을 다시 열었다. 저장소도 기준선도 새로 만들지만 단말에 남은 값을 읽는다.
final backend = _RecordingBackend(db);
final second = FirestoreQuestRepository(
backend,
stepCount: () => Stream.fromIterable([46_200]),
);
expect(await second.progressStream(card).toList(), [6_200]);
await second.verify(card);
expect(backend.calls.single.payload['steps'], 6_200);
});
test('출석도 퀘스트 문서 ID 를 함께 보낸다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
final cards = await repo.load();
await repo.verify(_card(cards, 'attend'));
expect(backend.calls.single.payload['questId'], 'attend');
});
test('걸음 수를 아직 세지 못했으면 서버를 부르지 않는다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
final cards = await repo.load();
await expectLater(
repo.verify(_card(cards, 'steps')),
throwsA(
isA<BackendException>().having((e) => e.code, 'code', 'E-QST-01'),
),
);
expect(backend.calls, isEmpty);
});
test('위치는 좌표와 모의 위치 여부를 함께 보낸다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(
backend,
placeFix: () async => const PlaceFix(
lat: 37.5563,
lng: 126.9236,
accuracyM: 12,
mocked: true,
),
);
final cards = await repo.load();
await repo.verify(_card(cards, 'place'));
final call = backend.calls.single;
expect(call.name, 'fnQuestVerifyPlace');
expect(call.payload['placeId'], 'hongdae');
expect(call.payload['questId'], 'place');
expect(call.payload['lat'], 37.5563);
// 판정은 서버가 한다. 앱은 모의 위치 여부를 감추지도, 스스로 막지도 않는다.
expect(call.payload['mocked'], isTrue);
});
test('장소가 등록되지 않은 위치 퀘스트는 서버를 부르지 않는다', () async {
final db = await _seed();
await db.doc('quests/place').set({
'type': 'location',
'title': '장소 미등록',
'rewardPop': 100,
'dailyLimit': 1,
});
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
final cards = await repo.load();
await expectLater(
repo.verify(_card(cards, 'place')),
throwsA(
isA<BackendException>().having((e) => e.code, 'code', 'E-QST-03'),
),
);
expect(backend.calls, isEmpty);
});
test('회신 대기 유형은 어느 함수도 부르지 않는다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
final cards = await repo.load();
final card = _card(cards, 'stream');
expect(card.type.verifiable, isFalse);
await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
expect(backend.calls, isEmpty);
});
test('생성은 fn-quest-create 로 — 종료일은 날짜 + 그 시각의 UTC 오프셋(C-06)', () async {
final db = await _seed();
final backend = _RecordingBackend(db, reply: const {'questId': 'q-new'});
final repo = FirestoreQuestRepository(backend);
final id = await repo.create(
QuestDraft(
clientSeq: 's1',
activityKey: 'vote',
title: '투표',
rewardPop: 5,
participants: 10,
endDate: DateTime(2026, 10, 3),
url: 'https://x.com/a',
manualTitle: '제목',
),
);
expect(id, 'q-new');
final call = backend.calls.single;
expect(call.name, 'fnQuestCreate');
expect(call.payload, containsPair('endDate', '2026-10-03'));
expect(call.payload, containsPair('capacity', 10));
expect(call.payload, containsPair('manualTitle', '제목'));
expect(
call.payload['utcOffsetMinutes'],
DateTime(2026, 10, 3, 23, 59, 59).timeZoneOffset.inMinutes,
);
// 앱은 quests 에 직접 쓰지 않는다.
expect((await db.collection('quests').get()).docs, hasLength(4));
});
test(
'분류 마스터 — 노출 · ugcAllowed · 순서를 따르고 만들 활동이 없는 분류는 감춘다(C-01)',
() async {
final db = await _seed();
await db.doc('config/policy').update({
'ugcFeeMin': 20,
'ugcMaxDays': 14,
});
await db.doc('config/questCatalog').set({
'categories': [
{'key': 'location', 'label': '특정 위치', 'order': 2, 'visible': true},
{'key': 'planet', 'label': '행성 활동', 'order': 1, 'visible': true},
{'key': 'health', 'label': '건강', 'order': 3, 'visible': true},
{'key': 'community', 'label': '커뮤니티', 'order': 4, 'visible': false},
],
'activities': [
{
'key': 'sns',
'categoryKey': 'planet',
'order': 2,
'visible': true,
'ugcAllowed': true,
'inputSchema': [
{'field': 'link', 'required': true},
],
},
{
'key': 'vote',
'categoryKey': 'planet',
'order': 1,
'visible': true,
'ugcAllowed': true,
'inputSchema': [
{'field': 'link', 'required': true},
],
},
{
'key': 'youtube',
'categoryKey': 'planet',
'order': 3,
'visible': true,
'ugcAllowed': false,
},
{
'key': 'walk',
'categoryKey': 'health',
'order': 1,
'visible': true,
'ugcAllowed': false,
},
{
'key': 'location',
'categoryKey': 'location',
'order': 1,
'visible': true,
'ugcAllowed': true,
'inputSchema': [
{'field': 'place', 'required': true},
],
},
{
'key': 'qr',
'categoryKey': 'location',
'order': 2,
'visible': true,
'ugcAllowed': false,
},
{
'key': 'community',
'categoryKey': 'community',
'order': 1,
'visible': true,
'ugcAllowed': true,
},
],
});
final catalog = await FirestoreQuestRepository(
_RecordingBackend(db),
).catalog();
expect(catalog.categories.map((c) => c.id), ['planet', 'location']);
expect(catalog.categories.first.activities.map((a) => a.key), [
'vote',
'sns',
]);
expect(catalog.categories.first.activities.first.fields, {'link'});
expect(catalog.categories.last.activities.single.fields, {'place'});
expect(catalog.feeOf(50), 20);
// Q-UQ-02 권고 A안 — 보상 없는 Quest 는 수수료 0.
expect(catalog.feeOf(0), 0);
expect(catalog.maxDays, 14);
},
);
test('제출 · 검토 · 수정은 각 함수로만 간다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
await repo.submit('q1', url: 'https://x.com/p', text: '');
await repo.review(
'q1_u',
approve: false,
reason: '불명확',
resubmitAllowed: true,
);
await repo.edit('q1', title: 't', description: 'd');
expect(backend.calls.map((c) => c.name), [
'fnQuestSubmit',
'fnQuestReview',
'fnQuestEdit',
]);
expect(backend.calls.first.payload, {
'questId': 'q1',
'url': 'https://x.com/p',
});
expect(backend.calls[1].payload, {
'submissionId': 'q1_u',
'action': 'reject',
'reason': '불명확',
'resubmitAllowed': true,
});
});
test('마감이 지난 남의 사용자 생성 Quest 는 목록에서 빠진다(G-04)', () async {
final db = await _seed();
await db.doc('quests/ugc-old').set({
'type': 'vote',
'title': '지난 투표',
'authorUid': 'someone',
'status': 'published',
'endsAt': DateTime.now().subtract(const Duration(minutes: 1)),
});
await db.doc('quests/ugc-live').set({
'type': 'vote',
'title': '진행 투표',
'authorUid': 'someone',
'status': 'published',
'endsAt': DateTime.now().add(const Duration(hours: 3)),
'capacity': 5,
});
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load(limit: 20);
expect(cards.map((c) => c.id), isNot(contains('ugc-old')));
final live = _card(cards, 'ugc-live');
expect(live.type, QuestType.vote);
expect(live.author, QuestAuthor.user);
expect(live.capacity, 5);
});
});
}