feat(flutter): 펫 명함을 서버에 저장 (ApiPetCardRepository)

- 명함 목록·만들기·꾸미기·삭제·대표 명함을 /pet-cards, /pets/:id/representative-card 로 옮겼다
- 명함과 대표 지정을 한 번에 불러오도록 저장소가 PetCardBook 을 돌려준다
- 배경은 앱 그림(assets/…)이면 경로만, 기기 사진이면 올린 뒤 파일 id 로 보낸다
- 공유 화면은 서버가 발급한 명함 코드를 보여 준다 (기기 저장 모드는 지금처럼 명함 id 로 만든다)
- 명함을 지울 때 대표 지정 정리, 아이를 지울 때 명함 정리는 저장소가 맡는다 — 서버 모드는 서버가 이미 했다
- 명함 화면은 실패하면 꾸민 모양을 그대로 둔다 (알림은 인터셉터)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:32:38 +09:00
parent a09086daf7
commit 2c1d1cc97d
11 changed files with 329 additions and 46 deletions
@@ -0,0 +1,113 @@
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_card/data/api_pet_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'api_account_test.dart' show kPngBytes;
import 'support/fake_api.dart';
Map<String, Object?> _cardJson({
String id = 'c1',
String petId = 'p1',
bool isRepresentative = false,
String? nameBgAsset,
String? nameBgFileId,
}) =>
{
'id': id,
'petId': petId,
'code': '0428-117',
'isRepresentative': isRepresentative,
'style': {
'tint': '#5AA0E0',
'tint2': '#9CCBF0',
'nameBg': null,
'nameBgAsset': nameBgAsset,
'nameBgFileId': nameBgFileId,
'textLight': true,
'scrim': true,
'scrimTone': 'dark',
},
};
void main() {
late FakeApi api;
late ApiPetCardRepository repo;
setUp(() {
api = FakeApi();
repo = ApiPetCardRepository(dio: api.dio, session: api.session, uploader: FileUploader(api.dio, api.reported.add));
});
test('명함 목록에서 대표 명함을 아이별로 모으고, 서버가 발급한 코드를 담는다', () async {
api.on('GET', '/pet-cards', (r) => ok(page([
_cardJson(nameBgAsset: 'assets/svg/card_bg/bg_1.svg'),
_cardJson(id: 'c2', isRepresentative: true, nameBgFileId: 'f1'),
_cardJson(id: 'c3', petId: 'p2'),
])));
final book = await repo.fetch();
expect(book.cards.map((c) => c.id), ['c1', 'c2', 'c3']);
expect(book.representatives, {'p1': 'c2'});
expect(book.cards.first.code, '0428-117');
expect(book.cards.first.style.nameBgImage, 'assets/svg/card_bg/bg_1.svg');
expect(book.cards[1].style.nameBgImage, 'server-file:f1');
expect(book.cards.first.style.scrimTone, ScrimTone.dark);
});
test('앱 그림 배경은 경로만 보내고 올리지 않는다', () async {
api.on('PUT', '/pet-cards/:id', (r) {
final style = r.json!['style'] as Map<String, dynamic>;
expect(style['nameBgAsset'], 'assets/svg/card_bg/bg_2.svg');
expect(style['nameBgFileId'], isNull);
return ok(_cardJson(nameBgAsset: 'assets/svg/card_bg/bg_2.svg'));
});
await repo.update('c1', CardStyle.initial().withBackgroundImage('assets/svg/card_bg/bg_2.svg'));
expect(api.sent('POST', '/files'), isEmpty);
});
test('기기 사진 배경은 올린 뒤 파일 id 로 보낸다', () async {
final dir = Directory.systemTemp.createTempSync('rup-card');
addTearDown(() => dir.deleteSync(recursive: true));
final photo = File('${dir.path}/bg.png')..writeAsBytesSync(kPngBytes);
api.on('POST', '/files', (r) => ok({'id': 'f7'}, status: 201));
api.on('POST', '/pet-cards', (r) {
expect(r.json!['petId'], 'p1');
final style = r.json!['style'] as Map<String, dynamic>;
expect(style['nameBgFileId'], 'f7');
expect(style['nameBgAsset'], isNull);
expect(style['tint'], '#FF9B6A');
return ok(_cardJson(nameBgFileId: 'f7'), status: 201);
});
final card = await repo.create('p1', CardStyle.forPet(const Color(0xFFFF9B6A)).withBackgroundImage(photo.path));
expect(card.style.nameBgImage, 'server-file:f7');
});
test('대표 명함은 아이의 하위 경로로 정하고 푼다', () async {
api.on('PUT', '/pets/:petId/representative-card', (r) {
expect(r.json, {'cardId': 'c1'});
return noContent;
});
api.on('DELETE', '/pets/:petId/representative-card', (r) => noContent);
await repo.setRepresentative('p1', 'c1');
await repo.clearRepresentative('p1');
expect(api.sent('PUT', '/pets/p1/representative-card'), hasLength(1));
expect(api.sent('DELETE', '/pets/p1/representative-card'), hasLength(1));
});
test('아이를 지울 때 명함 정리는 서버가 이미 했다', () async {
await repo.dropPet('p1');
expect(api.requests, isEmpty);
});
}
+33 -4
View File
@@ -113,21 +113,50 @@ void main() {
await repo.create('pet-1', CardStyle.initial());
await repo.update(a.id, CardStyle.initial().withTextLight(true));
var cards = await repo.fetchCards();
var cards = (await repo.fetch()).cards;
expect(cards, hasLength(2));
expect(cards.first.style.textLight, isTrue);
await repo.delete(a.id);
cards = await repo.fetchCards();
cards = (await repo.fetch()).cards;
expect(cards.map((c) => c.id), isNot(contains(a.id)));
});
test('대표 명함을 아이별로 기억하고 지울 수 있다', () async {
final a = await repo.create('pet-1', CardStyle.initial());
await repo.setRepresentative('pet-1', a.id);
expect(await repo.fetchRepresentatives(), {'pet-1': a.id});
expect((await repo.fetch()).representatives, {'pet-1': a.id});
await repo.clearRepresentative('pet-1');
expect(await repo.fetchRepresentatives(), isEmpty);
expect((await repo.fetch()).representatives, isEmpty);
});
test('명함을 지우면 그 명함을 가리키던 대표 지정도 사라진다', () async {
final a = await repo.create('pet-1', CardStyle.initial());
await repo.setRepresentative('pet-1', a.id);
await repo.delete(a.id);
expect((await repo.fetch()).representatives, isEmpty);
});
test('아이를 지우면 그 아이의 명함과 대표 지정을 모두 지운다', () async {
final a = await repo.create('pet-1', CardStyle.initial());
await repo.create('pet-2', CardStyle.initial());
await repo.setRepresentative('pet-1', a.id);
await repo.dropPet('pet-1');
final book = await repo.fetch();
expect(book.cards.map((c) => c.petId), ['pet-2']);
expect(book.representatives, isEmpty);
});
test('명함 코드는 서버가 발급한 값, 없으면 명함 id 로 만든다', () {
final local = PetCard(id: 'c1', petId: 'p1', style: CardStyle.initial());
const fromServer = PetCard(id: 'c1', petId: 'p1', style: CardStyle(tint: Color(0xFFFF9B6A), tint2: Color(0xFFFF9B6A)), code: '0428-117');
expect(displayCardCode(local), petCardCode('c1'));
expect(displayCardCode(fromServer), '0428-117');
});
test('명함 JSON 을 그대로 되살릴 수 있다', () {