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:
2026-10-11 18:36:33 +09:00
parent 9cb660a0ac
commit 42abefcc66
@@ -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')),
);
});
}