diff --git a/flutter/test_live/api_contract_live_test.dart b/flutter/test_live/api_contract_live_test.dart new file mode 100644 index 0000000..3be68a6 --- /dev/null +++ b/flutter/test_live/api_contract_live_test.dart @@ -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 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 = []; + 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>( + '$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().having((e) => (e.error! as ApiException).code, 'code', 'DIARY_002')), + ); + }); +}