generated from ttipo/project-base
test(flutter): 서버 계약 라이브 점검 추가
단위 테스트는 응답 모양을 가정한 가짜 서버로 돈다. 그 가정이 실제 서버와 맞는지 개발 서버에 앱의 서버 저장소를 그대로 붙여 확인한다 (로그인 → 프로필 → 아이 → 일정 → 다이어리 → 명함 → 보관·삭제 때 서버 정리 → 탈퇴). 서버가 필요하므로 test/ 가 아닌 test_live/ 에 두어 CI 의 flutter test 에서는 돌지 않는다. cd flutter && flutter test test_live Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
// 서버 계약 라이브 점검 — 실제로 떠 있는 개발 서버에 앱의 서버 저장소를 붙여 본다.
|
||||
//
|
||||
// 단위 테스트(test/api_*_test.dart)는 응답 모양을 가정한 가짜 서버로 돈다. 이 파일은 그 가정이
|
||||
// 실제 서버와 맞는지 확인한다. 서버가 필요하므로 CI(`flutter test` = test/ 폴더)에서는 돌지 않는다.
|
||||
//
|
||||
// docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
// cd flutter && flutter test test_live
|
||||
//
|
||||
// 다른 주소면 LIVE_API_URL 로 바꾼다 (예: LIVE_API_URL=http://10.0.2.2:3000/api).
|
||||
// 개발용 로그인으로 새 회원을 만들고, 끝나면 탈퇴해 기록을 지운다.
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/core/api/api_exception.dart';
|
||||
import 'package:tma_app/core/api/dio_client.dart';
|
||||
import 'package:tma_app/core/api/file_uploader.dart';
|
||||
import 'package:tma_app/core/api/server_file.dart';
|
||||
import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/core/utils/hour_minute.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/device_key_store.dart';
|
||||
import 'package:tma_app/features/auth/domain/social_provider.dart';
|
||||
import 'package:tma_app/features/diary/data/api_diary_repository.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/domain/mood.dart';
|
||||
import 'package:tma_app/features/mypage/data/profile_repository.dart';
|
||||
import 'package:tma_app/features/pet/data/repositories/api_pet_repository.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet_archive.dart';
|
||||
import 'package:tma_app/features/pet_card/data/api_pet_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/schedule/data/api_schedule_repository.dart';
|
||||
import 'package:tma_app/features/schedule/domain/event_category.dart';
|
||||
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
|
||||
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
|
||||
|
||||
/// 1×1 PNG — 사진 올리기용
|
||||
final Uint8List _png = Uint8List.fromList(const [
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, //
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, //
|
||||
0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0xF8, 0x0F, 0x00, 0x00, //
|
||||
0x01, 0x01, 0x00, 0x05, 0x18, 0xD8, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, //
|
||||
0x42, 0x60, 0x82,
|
||||
]);
|
||||
|
||||
PetInput _pet(String name, {String? photoPath}) => PetInput(
|
||||
name: name,
|
||||
species: '강아지',
|
||||
breed: '말티즈',
|
||||
gender: PetGender.male,
|
||||
neutered: true,
|
||||
birthDate: DateTime(2021, 9, 14),
|
||||
weight: 3.2,
|
||||
weightUnit: WeightUnit.kg,
|
||||
photoPath: photoPath,
|
||||
allergies: const ['닭고기'],
|
||||
watchConditions: const ['슬개골'],
|
||||
);
|
||||
|
||||
EventDetails _details(String title, List<String> targets, {HourMinute? start}) =>
|
||||
EventDetails(title: title, kind: EventKind.general, category: EventCategory.walk, targetIds: targets, start: start);
|
||||
|
||||
void main() {
|
||||
final baseUrl = Platform.environment['LIVE_API_URL'] ?? 'http://localhost:3000/api';
|
||||
final reported = <ApiException>[];
|
||||
late SessionStore session;
|
||||
late Dio dio;
|
||||
late SharedPreferences prefs;
|
||||
late FileUploader uploader;
|
||||
late ApiAuthRepository auth;
|
||||
late Directory temp;
|
||||
late String photoPath;
|
||||
|
||||
setUpAll(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
session = SessionStore(MemoryTokenVault());
|
||||
dio = DioClient(baseUrl: baseUrl, session: session, onError: reported.add).dio;
|
||||
uploader = FileUploader(dio, reported.add);
|
||||
temp = Directory.systemTemp.createTempSync('rup-live');
|
||||
photoPath = (File('${temp.path}/photo.png')..writeAsBytesSync(_png)).path;
|
||||
|
||||
final deviceKey = 'live-${DateTime.now().microsecondsSinceEpoch}-${Random().nextInt(1 << 30)}';
|
||||
auth = ApiAuthRepository(dio: dio, session: session, prefs: prefs, deviceKeys: FixedDeviceKeyStore(deviceKey));
|
||||
await auth.signIn(SocialProvider.kakao);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
if (session.hasSession) await auth.deleteAccount();
|
||||
temp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('로그인하면 세션·계정 종류·회원 id 가 남는다', () {
|
||||
expect(session.hasSession, isTrue);
|
||||
expect(auth.provider, SocialProvider.kakao);
|
||||
expect(prefs.getString(kAuthUserIdKey), isNotNull);
|
||||
});
|
||||
|
||||
test('프로필 — 닉네임·사진을 바꾸고 다시 읽는다', () async {
|
||||
final profiles = ApiProfileRepository(dio: dio, session: session, uploader: uploader, prefs: prefs);
|
||||
|
||||
final saved = await profiles.save(UserProfile(nickname: '라이브 집사', photoPath: photoPath));
|
||||
final fetched = await profiles.fetch();
|
||||
|
||||
expect(fetched, saved);
|
||||
expect(fetched.nickname, '라이브 집사');
|
||||
expect(serverFileIdOf(fetched.photoPath), isNotNull);
|
||||
|
||||
// 올린 사진을 Dio 로 받아 온다 (사진 표시와 같은 경로)
|
||||
final bytes = await dio.get<List<int>>(
|
||||
'$kFilesPath/${serverFileIdOf(fetched.photoPath)}',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
expect(bytes.data, _png);
|
||||
});
|
||||
|
||||
test('아이·일정·다이어리·명함이 서버와 같은 모양으로 오간다', () async {
|
||||
final pets = ApiPetRepository(dio: dio, session: session, uploader: uploader);
|
||||
final schedule = ApiScheduleRepository(dio: dio, session: session);
|
||||
final diaries = ApiDiaryRepository(
|
||||
dio: dio,
|
||||
session: session,
|
||||
uploader: uploader,
|
||||
myUserId: () => prefs.getString(kAuthUserIdKey),
|
||||
);
|
||||
final cards = ApiPetCardRepository(dio: dio, session: session, uploader: uploader);
|
||||
|
||||
// ── 아이 ──
|
||||
final kong = await pets.create(_pet('콩이', photoPath: photoPath), tint: kPetTints[0]);
|
||||
final dubu = await pets.create(_pet('두부'), tint: kPetTints[1]);
|
||||
expect(kong.photoPath, startsWith(kServerFilePrefix));
|
||||
expect(kong.allergies, ['닭고기']);
|
||||
|
||||
final edited = await pets.update(dubu.id, _pet('두부두부'));
|
||||
expect(edited.name, '두부두부');
|
||||
expect(edited.tint, kPetTints[1]);
|
||||
|
||||
final reordered = await pets.reorder([dubu.id, kong.id]);
|
||||
expect(reordered.map((p) => p.id), [dubu.id, kong.id]);
|
||||
|
||||
// ── 일정 ──
|
||||
final today = DateTime.now();
|
||||
final day = DateTime(today.year, today.month, today.day);
|
||||
final events = await schedule.createEvents([
|
||||
(date: day, details: _details('산책', [kong.id, 'self'], start: const HourMinute(8, 30))),
|
||||
(date: day, details: _details('병원', [dubu.id])),
|
||||
]);
|
||||
final rule = await schedule.createRule(
|
||||
day,
|
||||
const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.week, n: 2),
|
||||
_details('목욕', [kong.id]),
|
||||
);
|
||||
await schedule.setDone(events.first.id, done: true);
|
||||
await schedule.setDone(ScheduleEvent.instanceId(rule.id, day), done: true);
|
||||
|
||||
var book = await schedule.fetch();
|
||||
expect(book.events, hasLength(2));
|
||||
expect(book.isDone(events.first.id), isTrue);
|
||||
expect(book.isDone(ScheduleEvent.instanceId(rule.id, day)), isTrue);
|
||||
expect(book.eventsOn(day.add(const Duration(days: 14))).single.details.title, '목욕');
|
||||
expect(book.events.firstWhere((e) => e.id == events.first.id).details.start, const HourMinute(8, 30));
|
||||
|
||||
final updatedRule = await schedule.updateRule(
|
||||
rule,
|
||||
const RepeatSpec(RepeatPattern.daily),
|
||||
_details('목욕', [kong.id]),
|
||||
);
|
||||
expect(updatedRule.from, day);
|
||||
|
||||
// ── 다이어리 ──
|
||||
final diary = await diaries.createDiary(
|
||||
name: '콩이의 하루',
|
||||
targetIds: [kong.id],
|
||||
ribbon: kRibbonColors[2],
|
||||
tint: kDiaryTints[0],
|
||||
coverPath: photoPath,
|
||||
);
|
||||
expect(diary, isNotNull);
|
||||
final post = await diaries.addPost(
|
||||
diaryId: diary!.id,
|
||||
date: day,
|
||||
text: '산책했어요',
|
||||
mood: Mood.happy,
|
||||
photoPath: photoPath,
|
||||
);
|
||||
final comment = await diaries.addComment(post.id, '귀여워');
|
||||
await diaries.updatePostText(post, '산책 다녀왔어요');
|
||||
await diaries.updateComment(comment, '정말 귀여워');
|
||||
await diaries.updateDiary(diary.copyWith(mutedNotices: {DiaryNotice.reminder}));
|
||||
|
||||
final diaryBook = await diaries.fetch();
|
||||
expect(diaryBook.wroteOn(diary.id, day), isTrue);
|
||||
expect(diaryBook.posts.single.text, '산책 다녀왔어요');
|
||||
expect(diaryBook.comments.single.text, '정말 귀여워');
|
||||
expect(diaryBook.diaries.single.mutedNotices, {DiaryNotice.reminder});
|
||||
expect(diaryBook.diaries.single.coverPath, startsWith(kServerFilePrefix));
|
||||
|
||||
// ── 명함 ──
|
||||
final assetCard = await cards.create(
|
||||
kong.id,
|
||||
CardStyle.forPet(kong.tint).withBackgroundImage('assets/svg/card_bg/bg_1.svg'),
|
||||
);
|
||||
final photoCard = await cards.create(kong.id, CardStyle.initial().withBackgroundImage(photoPath));
|
||||
expect(assetCard.code, matches(RegExp(r'^\d{4}-\d{3}$')));
|
||||
expect(assetCard.style.nameBgImage, 'assets/svg/card_bg/bg_1.svg');
|
||||
expect(photoCard.style.nameBgImage, startsWith(kServerFilePrefix));
|
||||
await cards.setRepresentative(kong.id, photoCard.id);
|
||||
expect((await cards.fetch()).representatives, {kong.id: photoCard.id});
|
||||
|
||||
// ── 보관·삭제에 따른 서버 정리 ──
|
||||
final archived = await pets.archive(
|
||||
dubu.id,
|
||||
PetArchive(from: DateTime(2021, 9, 14), to: day, archivedAt: DateTime.now(), memo: '고마워'),
|
||||
);
|
||||
expect(archived.archive?.memo, '고마워');
|
||||
book = await schedule.fetch();
|
||||
expect(book.events.map((e) => e.details.title), ['산책'], reason: '보관한 아이만 대상인 일정은 지운다');
|
||||
expect((await pets.restore(dubu.id)).isArchived, isFalse);
|
||||
|
||||
await pets.delete(kong.id);
|
||||
book = await schedule.fetch();
|
||||
expect(book.events.single.details.targetIds, ['self'], reason: '지운 아이만 대상에서 뗀다');
|
||||
expect(book.rules, isEmpty, reason: '대상이 남지 않은 반복 일정은 지운다');
|
||||
expect((await cards.fetch()).cards, isEmpty, reason: '아이의 명함은 함께 지운다');
|
||||
expect((await diaries.fetch()).diaries.single.targetIds, isEmpty, reason: '다이어리는 남기고 대상에서만 뗀다');
|
||||
|
||||
expect(reported, isEmpty, reason: '정상 흐름에서는 실패 알림이 없어야 한다');
|
||||
});
|
||||
|
||||
test('규칙 위반은 사전 문구의 에러 코드로 돌아온다', () async {
|
||||
final diaries = ApiDiaryRepository(dio: dio, session: session, uploader: uploader, myUserId: () => null);
|
||||
final diary = (await diaries.fetch()).diaries.first;
|
||||
final today = DateTime.now();
|
||||
|
||||
await expectLater(
|
||||
diaries.addPost(diaryId: diary.id, date: DateTime(today.year, today.month, today.day), text: '두 번째'),
|
||||
throwsA(isA<DioException>().having((e) => (e.error! as ApiException).code, 'code', 'DIARY_002')),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user