feat(flutter): 우리 아이를 서버에 저장 (ApiPetRepository)

- 목록·등록·수정·삭제·순서·추억 보관함을 /pets 로 옮겼다. 목록은 마지막 페이지까지 이어 읽는다
- 아이 사진은 저장할 때 올리고, 이미 올린 사진(server-file:)은 다시 올리지 않는다
- 고유색은 알림기가 등록 순서대로 정해 넘긴다 — 기기·서버 저장소가 같은 규칙을 쓴다
- 보관한 때는 서버가 정하고, 회원이 바뀌면 목록을 다시 읽는다
- DATA_MODE=local 이면 지금까지처럼 기기에 저장한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:17:43 +09:00
parent 00660c7d62
commit 100dbe3a35
6 changed files with 328 additions and 19 deletions
+165
View File
@@ -0,0 +1,165 @@
import 'dart:io';
import 'dart:ui' show Color;
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/api/file_uploader.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 'api_account_test.dart' show kPngBytes;
import 'support/fake_api.dart';
Map<String, Object?> _petJson({String id = 'p1', String name = '콩이', String? photoFileId, Object? archive}) => {
'id': id,
'tint': '#6FB8E8',
'name': name,
'species': '강아지',
'breed': '말티즈',
'gender': 'female',
'neutered': true,
'birthDate': '2021-09-14',
'weight': 3.2,
'weightUnit': 'kg',
'photoFileId': photoFileId,
'microchip': null,
'bloodType': 'DEA 1.1',
'vet': null,
'memo': '간식 좋아함',
'allergies': ['닭고기'],
'conditions': <String>[],
'watchConditions': ['슬개골'],
'archive': archive,
};
PetInput _input({String? photoPath}) => PetInput(
name: '콩이',
species: '강아지',
breed: '말티즈',
gender: PetGender.female,
neutered: true,
birthDate: DateTime(2021, 9, 14),
weight: 3.2,
weightUnit: WeightUnit.kg,
photoPath: photoPath,
allergies: const ['닭고기'],
);
void main() {
late FakeApi api;
late ApiPetRepository repo;
setUp(() {
api = FakeApi();
repo = ApiPetRepository(dio: api.dio, session: api.session, uploader: FileUploader(api.dio, api.reported.add));
});
test('서버 응답을 앱의 Pet 으로 옮긴다 — 사진은 서버 파일, 보관 기록은 PetArchive', () {
final pet = petFromApi(
_petJson(
photoFileId: 'f1',
archive: {'from': '2021-09-14', 'to': '2026-10-01', 'archivedAt': '2026-10-11T08:00:00.000Z', 'memo': '고마워'},
),
);
expect(pet.id, 'p1');
expect(pet.tint, const Color(0xFF6FB8E8));
expect(pet.gender, PetGender.female);
expect(pet.birthDate, DateTime(2021, 9, 14));
expect(pet.photoPath, 'server-file:f1');
expect(pet.allergies, ['닭고기']);
expect(pet.watchConditions, ['슬개골']);
expect(pet.archive?.to, DateTime(2026, 10, 1));
expect(pet.archive?.memo, '고마워');
});
test('목록은 마지막 페이지까지 이어 읽는다', () async {
api.on(
'GET',
'/pets',
(r) =>
r.query['cursor'] == null ? ok(page([_petJson(id: 'a')], nextCursor: 'c1')) : ok(page([_petJson(id: 'b')])),
);
final pets = await repo.fetchPets();
expect(pets.map((p) => p.id), ['a', 'b']);
expect(api.sent('GET', '/pets').map((r) => r.query['limit']), [100, 100]);
});
test('로그인 전에는 목록을 부르지 않는다', () async {
final signedOut = FakeApi(tokens: null);
final r = ApiPetRepository(
dio: signedOut.dio,
session: signedOut.session,
uploader: FileUploader(signedOut.dio, (_) {}),
);
expect(await r.fetchPets(), isEmpty);
expect(signedOut.requests, isEmpty);
});
test('등록할 때 기기 사진을 먼저 올리고 고유색과 함께 보낸다', () async {
final dir = Directory.systemTemp.createTempSync('rup-pet');
addTearDown(() => dir.deleteSync(recursive: true));
final photo = File('${dir.path}/kong.png')..writeAsBytesSync(kPngBytes);
api.on('POST', '/files', (r) => ok({'id': 'f9'}, status: 201));
api.on('POST', '/pets', (r) {
expect(r.json?['tint'], '#FF9B6A');
expect(r.json?['photoFileId'], 'f9');
expect(r.json?['birthDate'], '2021-09-14');
expect(r.json?['gender'], 'female');
return ok(_petJson(photoFileId: 'f9'), status: 201);
});
final pet = await repo.create(_input(photoPath: photo.path), tint: const Color(0xFFFF9B6A));
expect(pet.photoPath, 'server-file:f9');
});
test('수정할 때 이미 올린 사진은 다시 올리지 않는다', () async {
api.on('PATCH', '/pets/:id', (r) {
expect(r.params['id'], 'p1');
expect(r.json?['photoFileId'], 'f1');
expect(r.json!.containsKey('tint'), isFalse);
return ok(_petJson(photoFileId: 'f1'));
});
await repo.update('p1', _input(photoPath: 'server-file:f1'));
expect(api.sent('POST', '/files'), isEmpty);
});
test('보관하기는 날짜만 보내고(보관한 때는 서버가 정한다) 다시 불러오기는 보관 기록을 지운다', () async {
api.on('PUT', '/pets/:id/archive', (r) {
expect(r.json, {'from': '2021-09-14', 'to': '2026-10-01', 'memo': null});
return ok(
_petJson(
archive: {'from': '2021-09-14', 'to': '2026-10-01', 'archivedAt': '2026-10-11T08:00:00.000Z', 'memo': null},
),
);
});
api.on('DELETE', '/pets/:id/archive', (r) => ok(_petJson()));
final archived = await repo.archive(
'p1',
PetArchive(from: DateTime(2021, 9, 14), to: DateTime(2026, 10, 1), archivedAt: DateTime(2026, 10, 11)),
);
expect(archived.isArchived, isTrue);
expect((await repo.restore('p1')).isArchived, isFalse);
});
test('순서를 저장한 뒤 서버 순서로 다시 읽는다', () async {
api.on('PUT', '/pets/order', (r) {
expect(r.json, {
'ids': ['b', 'a'],
});
return noContent;
});
api.on('GET', '/pets', (r) => ok(page([_petJson(id: 'b'), _petJson(id: 'a')])));
final pets = await repo.reorder(['b', 'a']);
expect(pets.map((p) => p.id), ['b', 'a']);
});
}
+1 -1
View File
@@ -74,7 +74,7 @@ void main() {
Future<LocalPetRepository> repo() async {
SharedPreferences.setMockInitialValues({});
final r = LocalPetRepository(await SharedPreferences.getInstance());
await r.create(samplePet());
await r.create(samplePet(), tint: kPetTints.first);
return r;
}
+5 -5
View File
@@ -120,22 +120,22 @@ void main() {
});
test('등록한 순서대로 목록에 쌓인다', () async {
await repo.create(_input(name: '콩이'));
await repo.create(_input(name: '보리'));
await repo.create(_input(name: '콩이'), tint: kPetTints.first);
await repo.create(_input(name: '보리'), tint: kPetTints.first);
final pets = await repo.fetchPets();
expect(pets.map((p) => p.name), ['콩이', '보리']);
expect(pets.map((p) => p.id).toSet().length, 2);
});
test('수정하면 같은 자리의 값이 바뀐다', () async {
final a = await repo.create(_input(name: '콩이'));
await repo.create(_input(name: '보리'));
final a = await repo.create(_input(name: '콩이'), tint: kPetTints.first);
await repo.create(_input(name: '보리'), tint: kPetTints.first);
await repo.update(a.id, _input(name: '콩순이'));
expect((await repo.fetchPets()).map((p) => p.name), ['콩순이', '보리']);
});
test('삭제하면 목록에서 빠진다', () async {
final a = await repo.create(_input(name: '콩이'));
final a = await repo.create(_input(name: '콩이'), tint: kPetTints.first);
await repo.delete(a.id);
expect(await repo.fetchPets(), isEmpty);
});