feat(flutter): 일정을 서버에 저장 (ApiScheduleRepository)

일정 저장소가 "목록 전체 저장" 이라 서버에 맞지 않아 건별 계약으로 바꿨다.

- 저장소: 날짜 일정 여러 건 만들기·내용 바꾸기·삭제, 반복 규칙 만들기·바꾸기·삭제, 완료 표시, 대상 떼기.
  돌려받은 결과(id 가 붙은 일정·규칙)를 알림기가 ScheduleBook 에 반영한다
- 일반↔반복 전환은 알림기가 저장소 호출로 풀고, 새 것을 먼저 만든 뒤 옛것을 지운다(중간 실패에도 일정이 남는다).
  ScheduleBook.update 는 지우고 같은 규칙을 알림기 테스트로 옮겼다
- 서버 저장소: 불러올 때 날짜 일정·반복 규칙·완료 표시를 합치고, 기간 설정은 500건씩 나눠 보낸다.
  아이 삭제·보관 때의 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다
- 완료 체크는 화면에 먼저 반영하고 저장에 실패하면 되돌린다
- 일정·아이 저장 화면은 실패하면(알림은 인터셉터) 입력을 둔 채 머문다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:23:55 +09:00
parent 100dbe3a35
commit 60efc65ee0
13 changed files with 681 additions and 119 deletions
@@ -0,0 +1,150 @@
import 'dart:ui' show Color;
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/utils/hour_minute.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';
import 'support/fake_api.dart';
const EventDetails _details = EventDetails(
title: '병원',
kind: EventKind.reservation,
category: EventCategory.hospital,
targetIds: ['p1', 'self'],
start: HourMinute(10, 30),
end: HourMinute(11, 0),
place: '동물병원',
labelColor: Color(0xFFFF6B6B),
);
Map<String, Object?> _detailsJson() => {
'title': '병원',
'kind': 'reservation',
'category': 'hospital',
'targetIds': ['p1', 'self'],
'start': '10:30',
'end': '11:00',
'place': '동물병원',
'labelColor': '#FF6B6B',
};
Map<String, Object?> _eventJson(String id, String date, {bool isDone = false}) =>
{'id': id, 'date': date, 'isDone': isDone, ..._detailsJson()};
void main() {
late FakeApi api;
late ApiScheduleRepository repo;
setUp(() {
api = FakeApi();
repo = ApiScheduleRepository(dio: api.dio, session: api.session);
});
test('일정 내용은 서버 형식(HH:mm · #RRGGBB)으로 오가도 같다', () {
expect(eventDetailsToApi(_details), _detailsJson());
final back = eventDetailsFromApi(_detailsJson());
expect(back.start, const HourMinute(10, 30));
expect(back.labelColor, const Color(0xFFFF6B6B));
expect(back.targetIds, ['p1', 'self']);
});
test('불러오기는 날짜 일정·반복 규칙·완료 표시를 하나의 일정 장부로 합친다', () async {
api.on('GET', '/schedule-events', (r) => ok(page([
_eventJson('e1', '2026-10-12', isDone: true),
_eventJson('e2', '2026-10-13'),
])));
api.on('GET', '/repeat-rules', (r) => ok(page([
{
'id': 'r1',
'from': '2026-10-11',
'spec': {'mode': 'every', 'unit': 'week', 'n': 2},
'details': _detailsJson(),
},
])));
api.on('GET', '/repeat-rule-completions', (r) => ok(page([
{'ruleId': 'r1', 'date': '2026-10-11'},
])));
final book = await repo.fetch();
expect(book.events.map((e) => e.id), ['e1', 'e2']);
expect(book.rules.single.spec, const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.week, n: 2));
expect(book.doneIds, {'e1', 'r1@2026-10-11'});
expect(book.eventsOn(DateTime(2026, 10, 25)).single.ruleId, 'r1');
});
test('기간 설정처럼 많은 일정은 500건씩 나눠 보낸다', () async {
final sizes = <int>[];
api.on('POST', '/schedule-events', (r) {
final events = r.json!['events'] as List<dynamic>;
sizes.add(events.length);
return ok([
for (final (i, e) in events.indexed) _eventJson('e${sizes.length}-$i', (e as Map<String, dynamic>)['date'] as String),
], status: 201);
});
final drafts = [
for (var i = 0; i < 1001; i++) (date: DateTime(2026, 1, 1).add(Duration(days: i)), details: _details),
];
final created = await repo.createEvents(drafts);
expect(sizes, [500, 500, 1]);
expect(created, hasLength(1001));
expect(created.first.date, DateTime(2026, 1, 1));
});
test('완료 표시는 날짜 일정과 반복 일정의 경로가 다르다', () async {
api.on('PUT', '/schedule-events/:id/completion', (r) => noContent);
api.on('DELETE', '/repeat-rules/:id/completions/:date', (r) {
expect(r.params, {'id': 'r1', 'date': '2026-10-11'});
return noContent;
});
await repo.setDone('e1', done: true);
await repo.setDone(ScheduleEvent.instanceId('r1', DateTime(2026, 10, 11)), done: false);
expect(api.sent('PUT', '/schedule-events/e1/completion'), hasLength(1));
expect(api.sent('DELETE', '/repeat-rules/r1/completions/2026-10-11'), hasLength(1));
});
test('반복 주기·내용 바꾸기는 시작한 날을 보내지 않는다', () async {
api.on('PUT', '/repeat-rules/:id', (r) {
expect(r.json!.keys, unorderedEquals(['spec', 'details']));
return ok({
'id': 'r1',
'from': '2026-10-11',
'spec': {'mode': 'daily', 'unit': 'week', 'n': 1},
'details': _detailsJson(),
});
});
final rule = RepeatRule(
id: 'r1',
spec: const RepeatSpec(RepeatPattern.every),
from: DateTime(2026, 10, 11),
details: _details,
);
final updated = await repo.updateRule(rule, const RepeatSpec(RepeatPattern.daily), _details);
expect(updated.spec.mode, RepeatPattern.daily);
});
test('아이를 지울 때 일정 정리는 서버가 이미 했다 — 아무것도 보내지 않는다', () async {
await repo.dropTarget('p1');
expect(api.requests, isEmpty);
});
test('로그인 전에는 불러오지 않는다', () async {
final signedOut = FakeApi(tokens: null);
final book = await ApiScheduleRepository(dio: signedOut.dio, session: signedOut.session).fetch();
expect(book.events, isEmpty);
expect(signedOut.requests, isEmpty);
});
}
+1 -27
View File
@@ -105,33 +105,7 @@ void main() {
expect(found?.isRepeat, isTrue);
});
test('일반 일정을 반복으로 바꾸면 그 날부터 규칙이 된다', () {
final target = _event('a', day, _details('산책'));
final book = ScheduleBook(events: [target]).update(
target,
_details('산책'),
spec: const RepeatSpec(RepeatPattern.daily),
newEventId: () => 'e-new',
newRuleId: () => 'r-new',
);
expect(book.events, isEmpty);
expect(book.rules.single.from, day);
expect(book.eventsOn(DateTime(2026, 9, 30)).single.details.title, '산책');
});
test('반복을 끄면 보고 있던 날짜의 일정 하나만 남고 그날 완료 표시는 지운다', () {
final rule = RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책'));
final instance = rule.instanceOn(DateTime(2026, 10, 1));
final book = ScheduleBook(rules: [rule], doneIds: {instance.id}).update(
instance,
_details('산책'),
newEventId: () => 'e-new',
newRuleId: () => 'r-new',
);
expect(book.rules, isEmpty);
expect(book.events.single.date, DateTime(2026, 10, 1));
expect(book.doneIds, isEmpty);
});
// 일반 ↔ 반복 전환 규칙은 저장소를 거치므로 schedule_notifier_test 가 검사한다
test('반복 일정을 지우면 규칙 전체가 지워진다', () {
final rule = RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책'));
+168
View File
@@ -0,0 +1,168 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/schedule/data/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_book.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart';
const EventDetails _walk = EventDetails(
title: '산책',
kind: EventKind.general,
category: EventCategory.walk,
targetIds: ['pet-1'],
);
final DateTime _day = DateTime(2026, 9, 30);
/// 저장소에 들어온 호출 순서를 기록한다 — 기기 저장소를 감싼다
class _RecordingRepository implements ScheduleRepository {
_RecordingRepository(this._inner);
final ScheduleRepository _inner;
final List<String> calls = [];
bool failSetDone = false;
@override
Future<ScheduleBook> fetch() => _inner.fetch();
@override
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) {
calls.add('createEvents');
return _inner.createEvents(drafts);
}
@override
Future<ScheduleEvent> updateEvent(ScheduleEvent event, EventDetails details) {
calls.add('updateEvent');
return _inner.updateEvent(event, details);
}
@override
Future<void> deleteEvent(String eventId) {
calls.add('deleteEvent');
return _inner.deleteEvent(eventId);
}
@override
Future<RepeatRule> createRule(DateTime from, RepeatSpec spec, EventDetails details) {
calls.add('createRule');
return _inner.createRule(from, spec, details);
}
@override
Future<RepeatRule> updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) {
calls.add('updateRule');
return _inner.updateRule(rule, spec, details);
}
@override
Future<void> deleteRule(String ruleId) {
calls.add('deleteRule');
return _inner.deleteRule(ruleId);
}
@override
Future<void> setDone(String occurrenceId, {required bool done}) {
if (failSetDone) throw Exception('저장 실패');
return _inner.setDone(occurrenceId, done: done);
}
@override
Future<void> dropTarget(String targetId) => _inner.dropTarget(targetId);
}
void main() {
late ProviderContainer container;
late _RecordingRepository repo;
setUp(() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
repo = _RecordingRepository(LocalScheduleRepository(prefs));
container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
dataModeProvider.overrideWithValue(DataMode.local),
scheduleRepositoryProvider.overrideWithValue(repo),
],
);
addTearDown(container.dispose);
await container.read(scheduleProvider.future);
});
ScheduleNotifier notifier() => container.read(scheduleProvider.notifier);
ScheduleBook book() => container.read(scheduleBookProvider);
test('일반 일정을 반복으로 바꾸면 그 날부터 규칙이 된다 — 규칙을 먼저 만들고 일정을 지운다', () async {
await notifier().addEvents([(date: _day, details: _walk)]);
final target = book().events.single;
repo.calls.clear();
await notifier().edit(target, _walk, spec: const RepeatSpec(RepeatPattern.daily));
expect(book().events, isEmpty);
expect(book().rules.single.from, _day);
expect(book().eventsOn(DateTime(2026, 10, 3)).single.details.title, '산책');
expect(repo.calls, ['createRule', 'deleteEvent']);
// 저장소에도 같은 결과가 남는다
final saved = await repo.fetch();
expect(saved.events, isEmpty);
expect(saved.rules, hasLength(1));
});
test('반복을 끄면 보고 있던 날짜의 일정 하나만 남고 그 규칙의 완료 표시는 지운다', () async {
await notifier().addRule(_day, const RepeatSpec(RepeatPattern.daily), _walk);
final instance = book().eventsOn(DateTime(2026, 10, 1)).single;
await notifier().toggleDone(instance.id);
expect(book().isDone(instance.id), isTrue);
repo.calls.clear();
await notifier().edit(instance, _walk);
expect(book().rules, isEmpty);
expect(book().events.single.date, DateTime(2026, 10, 1));
expect(book().doneIds, isEmpty);
expect(repo.calls, ['createEvents', 'deleteRule']);
});
test('반복 주기만 바꾸면 같은 규칙이 남고 시작한 날도 그대로다', () async {
await notifier().addRule(_day, const RepeatSpec(RepeatPattern.daily), _walk);
final instance = book().eventsOn(DateTime(2026, 10, 2)).single;
await notifier().edit(instance, _walk, spec: const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.day, n: 2));
final rule = book().rules.single;
expect(rule.id, instance.ruleId);
expect(rule.from, _day);
expect(rule.spec.label, '2일 마다');
});
test('완료 체크를 저장하지 못하면 화면의 체크도 되돌린다', () async {
await notifier().addEvents([(date: _day, details: _walk)]);
final id = book().events.single.id;
repo.failSetDone = true;
await expectLater(notifier().toggleDone(id), throwsException);
expect(book().isDone(id), isFalse);
});
test('기간 설정으로 바꾸면 새 일정을 먼저 만들고 원래 일정을 지운다', () async {
await notifier().addEvents([(date: _day, details: _walk)]);
final target = book().events.single;
repo.calls.clear();
await notifier().replace(target, [
(date: DateTime(2026, 10, 1), details: _walk),
(date: DateTime(2026, 10, 2), details: _walk),
]);
expect(book().events.map((e) => e.date.day), [1, 2]);
expect(repo.calls, ['createEvents', 'deleteEvent']);
});
}