631 lines
24 KiB
Dart
631 lines
24 KiB
Dart
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
|
import 'package:cloud_firestore/cloud_firestore.dart' show SetOptions;
|
|
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('비활성 문서가 첫 페이지를 채워도 11번째 이후 퀘스트가 보인다 (2026-10-06 실기기)', () async {
|
|
final db = FakeFirebaseFirestore();
|
|
// 문서 ID 순으로 앞 10건이 비활성(daily-*) — 걸러낸 뒤 개수로 끝을 판정하면 뒤가 사라진다.
|
|
for (var i = 0; i < 10; i++) {
|
|
await db.doc('quests/a-daily-$i').set({'type': 'attend', 'title': 'd$i', 'active': false});
|
|
}
|
|
await db.doc('quests/z-music').set({'type': 'music_shake', 'title': '박자 맞춰 흔들기'});
|
|
final repo = FirestoreQuestRepository(_RecordingBackend(db));
|
|
final first = await repo.load();
|
|
expect(first.map((c) => c.id), contains('z-music'));
|
|
// 10장보다 적으면 끝 — 다음 페이지는 비어 있다.
|
|
expect(first.length, lessThan(10));
|
|
expect(await repo.more(offset: first.length), isEmpty);
|
|
});
|
|
|
|
test('보이는 퀘스트가 10건을 넘으면 다음 페이지에서 11번째부터 온다', () async {
|
|
final db = FakeFirebaseFirestore();
|
|
for (var i = 0; i < 12; i++) {
|
|
await db.doc('quests/q${i.toString().padLeft(2, '0')}').set({'type': 'attend', 'title': 'q$i'});
|
|
}
|
|
final repo = FirestoreQuestRepository(_RecordingBackend(db));
|
|
final first = await repo.load();
|
|
expect(first, hasLength(10));
|
|
final next = await repo.more(offset: first.length);
|
|
expect(next, hasLength(2));
|
|
expect({...first, ...next}.map((c) => c.id).toSet(), hasLength(12));
|
|
});
|
|
|
|
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('Health 가 있으면 그 날 0시부터의 합계를 센서보다 먼저 쓴다 (B-02)', () async {
|
|
final db = await _seed();
|
|
final backend = _RecordingBackend(db);
|
|
final repo = FirestoreQuestRepository(
|
|
backend,
|
|
todaySteps: () async => 7_300,
|
|
stepCount: () => Stream.fromIterable([40_000]),
|
|
);
|
|
final card = _card(await repo.load(), 'steps');
|
|
expect(await repo.progressStream(card).first, 7_300);
|
|
await repo.verify(card);
|
|
expect(backend.calls.single.payload['steps'], 7_300);
|
|
});
|
|
|
|
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('위치 Quest 의 인증 방법을 상세에 싣고 QR 은 GPS 와 함께 보낸다 (C-03 · PRD-05)', () async {
|
|
final db = await _seed();
|
|
final backend = _RecordingBackend(db);
|
|
final repo = FirestoreQuestRepository(
|
|
backend,
|
|
placeFix: () async =>
|
|
const PlaceFix(lat: 37.5, lng: 126.9, accuracyM: 5, mocked: false),
|
|
);
|
|
// 수단을 적지 않은 운영 Quest 는 GPS 하나.
|
|
expect((await repo.detail('place')).verifyMethods, ['gps']);
|
|
await db.doc('quests/place').update({'verifyMethods': ['gps', 'qr']});
|
|
await db.doc('questSecrets/place').set({'authorUid': 'other', 'qrCode': 'secret'});
|
|
final detail = await repo.detail('place');
|
|
expect(detail.verifyMethods, ['gps', 'qr']);
|
|
expect(detail.qrCode, isNull, reason: '참여자에게는 QR 값을 주지 않는다');
|
|
|
|
await repo.verify(detail.card, proof: const PlaceProof(qr: 'scanned'));
|
|
final call = backend.calls.single;
|
|
expect(call.payload['qr'], 'scanned');
|
|
expect(call.payload['lat'], 37.5);
|
|
expect(call.payload.containsKey('photo'), isFalse);
|
|
});
|
|
|
|
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('시청 · 스트리밍은 무보상 참여 함수만 부른다(C-04) — 보상은 0', () async {
|
|
final db = await _seed();
|
|
final backend = _RecordingBackend(db, reply: const {'rewarded': 0, 'remaining': 0});
|
|
final repo = FirestoreQuestRepository(backend);
|
|
final card = _card(await repo.load(), 'stream');
|
|
|
|
expect(card.type.verifiable, isTrue);
|
|
// 문서에 rewardPop 10 이 남아 있어도 화면에 보상을 약속하지 않는다.
|
|
expect(card.rewardPop, 0);
|
|
final reward = await repo.verify(card);
|
|
expect(backend.calls.single.name, 'fnQuestStreamParticipate');
|
|
expect(backend.calls.single.payload, {'questId': 'stream'});
|
|
expect(reward.rewardedPop, 0);
|
|
});
|
|
|
|
test('Music Shake — 영상 · BPM · 구간 수를 퀘스트에서 읽고 판정표 · 곡 ID 를 서버에 보낸다', () async {
|
|
final db = await _seed();
|
|
await db.doc('config/policy').set({
|
|
'musicShake': {'perfectWindow': 0.1, 'goodWindow': 0.4, 'pop': 3},
|
|
}, SetOptions(merge: true));
|
|
await db.doc('quests/shake').set({
|
|
'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1,
|
|
'totalSegments': 3, 'bpm': 120,
|
|
'link': {'url': 'https://youtu.be/WMweEpGlu_U', 'title': 'BTS - Butter'},
|
|
});
|
|
final backend = _RecordingBackend(db);
|
|
final repo = FirestoreQuestRepository(backend);
|
|
final card = _card(await repo.load(), 'shake');
|
|
expect(card.type.verifiable, isTrue);
|
|
// Figma 「목표BPM」 — 목표 줄은 곡 BPM 이다. 보상은 운영값 musicShake.pop.
|
|
expect(card.goal, 120);
|
|
expect(card.rewardPop, 3);
|
|
|
|
final setup = await repo.musicShakeSetup('shake');
|
|
expect(setup.videoId, 'WMweEpGlu_U');
|
|
expect(setup.title, 'BTS - Butter');
|
|
expect((setup.bpm, setup.segments), (120.0, 3));
|
|
expect((setup.windows.perfect, setup.windows.good), (0.1, 0.4));
|
|
|
|
// 판을 끝내기 전에는 서버를 부르지 않는다.
|
|
await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
|
|
expect(backend.calls, isEmpty);
|
|
|
|
repo.recordShake(ShakeJudge(bpm: 120, segments: 3, windows: setup.windows)
|
|
..add(Duration.zero));
|
|
await repo.verify(card);
|
|
final call = backend.calls.single;
|
|
expect(call.name, 'fnQuestVerifyMusicShake');
|
|
expect(call.payload['questId'], 'shake');
|
|
expect(call.payload['trackId'], 'WMweEpGlu_U');
|
|
expect(call.payload['totalSegments'], 3);
|
|
expect(call.payload['judgement'], {'perfect': 1, 'good': 0, 'miss': 2, 'maxCombo': 1});
|
|
});
|
|
|
|
test('Music Shake — 영상 · BPM · 구간 수가 없으면 판을 열지 않는다(값을 지어내지 않는다)', () async {
|
|
final db = await _seed();
|
|
await db.doc('quests/shake').set({'type': 'musicshake', 'title': '흔들기', 'totalSegments': 3});
|
|
final repo = FirestoreQuestRepository(_RecordingBackend(db));
|
|
await expectLater(repo.musicShakeSetup('shake'), throwsA(isA<BackendException>()));
|
|
expect(_card(await repo.load(), 'shake').goal, 0);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|