Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions fastlane/metadata/ko/release_notes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
• 로그인을 앱 내부 화면 대신 기기의 브라우저로 진행하도록 변경해 더 안전하게 로그인할 수 있습니다.
• 예약을 취소한 직후 같은 기기를 다시 예약할 때 이미 사용 중이라고 잘못 안내되던 문제를 개선했습니다.
• 서버 오류 발생 시 더 정확한 안내 메시지가 표시되도록 개선했습니다.
• 세탁 진행 상태와 알림 목록이 더 정확하게 갱신되도록 개선했습니다.
5 changes: 3 additions & 2 deletions lib/features/alarm/data/data_sources/alarm_data_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ class AlarmDataSourceImpl implements AlarmDataSource {
return const AlarmListResponse(data: []);
}

// 서버 응답은 `{ notifications: [...] }` 형태로 봉투가 없다.
return AlarmListResponse.fromJson(castJsonMap(response.data));
final body = castJsonMap(response.data);
final data = body.containsKey('data') ? extractDataMap(body) : body;
return AlarmListResponse.fromJson(data);
}

@override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,18 @@ class ReservationPenaltyException implements Exception {
}

class ReservationActionNotifier extends AsyncNotifier<ActiveReservationModel?> {
Future<ActiveReservationModel?>? _reserveRequest;
Future<bool>? _cancelRequest;
/// 진행 중인 요청을 대상(기기/예약) 단위로 보관한다.
/// 키 없이 단일 슬롯에 담으면 다른 대상의 요청에 합류해 그 결과가
/// 이 호출의 반환값이 된다(#261).
final Map<String, Future<Object?>> _inflight = {};

@override
Future<ActiveReservationModel?> build() async => null;

Future<ActiveReservationModel?> reserve({required int machineId}) {
return _runSingleFlight(
currentRequest: _reserveRequest,
setRequest: (request) => _reserveRequest = request,
action: () => _reserveInternal(machineId: machineId),
'reserve:$machineId',
() => _reserveInternal(machineId: machineId),
);
}

Expand Down Expand Up @@ -109,9 +110,8 @@ class ReservationActionNotifier extends AsyncNotifier<ActiveReservationModel?> {

Future<bool> cancel({required int reservationId}) {
return _runSingleFlight(
currentRequest: _cancelRequest,
setRequest: (request) => _cancelRequest = request,
action: () => _cancelInternal(reservationId: reservationId),
'cancel:$reservationId',
() => _cancelInternal(reservationId: reservationId),
);
}

Expand Down Expand Up @@ -156,18 +156,20 @@ class ReservationActionNotifier extends AsyncNotifier<ActiveReservationModel?> {
ref.read(reservationSyncControllerProvider).stopPolling();
}

Future<T> _runSingleFlight<T>({
required Future<T>? currentRequest,
required void Function(Future<T>? request) setRequest,
required Future<T> Function() action,
}) {
/// 같은 [key] 로 들어온 중복 요청만 하나로 합친다.
///
/// 키가 다르면(다른 기기 예약, 다른 예약 취소) 각자 요청을 보낸다.
/// 합쳐 버리면 누르지 않은 대상의 결과가 반환값이 되어 호출부가 그것을
/// 성공으로 처리한다(#261).
Future<T> _runSingleFlight<T>(String key, Future<T> Function() action) {
final currentRequest = _inflight[key];
if (currentRequest != null) {
return currentRequest;
return currentRequest.then((value) => value as T);
}

final request = action();
setRequest(request);
request.whenComplete(() => setRequest(null));
_inflight[key] = request;
request.whenComplete(() => _inflight.remove(key));
return request;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,8 @@ class ReservationSyncController {
return;
}

final hasPendingReservation = latest.any(
(reservation) => reservation.laundryStatus == LaundryStatus.reserved,
);
if (!hasPendingReservation) {
final shouldKeepPolling = latest.any(_shouldKeepPolling);
if (!shouldKeepPolling) {
stopPolling();
}

Expand Down Expand Up @@ -117,4 +115,17 @@ class ReservationSyncController {

return listEquals(current, latest);
}

bool _shouldKeepPolling(ActiveReservationModel reservation) {
if (reservation.laundryStatus == LaundryStatus.reserved) {
return true;
}

return reservation.laundryStatus == LaundryStatus.inUse &&
!_hasText(reservation.expectedCompletionTime);
}

bool _hasText(String? value) {
return value != null && value.trim().isNotEmpty;
}
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: washer
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
Expand All @@ -16,7 +16,7 @@
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.1.5+6
version: 1.1.5+7

environment:
sdk: ^3.9.2
Expand Down
105 changes: 104 additions & 1 deletion test/features/reservation/reservation_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,23 @@ import 'package:washer/features/reservation/presentation/providers/reservation_p
class FakeReservationRemoteDataSource implements ReservationRemoteDataSource {
FakeReservationRemoteDataSource({
this.createdReservation = _reservedReservation,
this.createdReservationBuilder,
this.cancelError,
this.cancelResponse = _noPenaltyCancel,
});

final ActiveReservationModel createdReservation;

/// machineId 별로 다른 예약을 돌려줘야 하는 테스트용.
final ActiveReservationModel Function(int machineId)?
createdReservationBuilder;
final Object? cancelError;
final CancelReservationResponse cancelResponse;
int? lastMachineId;
String? lastStartTime;
int? cancelledId;
final List<int> createdMachineIds = [];
final List<int> cancelledIds = [];

@override
Future<ActiveReservationModel> createReservation({
Expand All @@ -37,12 +44,14 @@ class FakeReservationRemoteDataSource implements ReservationRemoteDataSource {
}) async {
lastMachineId = machineId;
lastStartTime = startTime;
return createdReservation;
createdMachineIds.add(machineId);
return createdReservationBuilder?.call(machineId) ?? createdReservation;
}

@override
Future<CancelReservationResponse> cancelReservation({required int id}) async {
cancelledId = id;
cancelledIds.add(id);
final nextError = cancelError;
if (nextError != null) {
throw nextError;
Expand Down Expand Up @@ -393,6 +402,100 @@ void main() {
]);
});

test('#261: 다른 기기를 연달아 누르면 각각 별도 요청이 나간다', () async {
final reservationDataSource = FakeReservationRemoteDataSource(
createdReservationBuilder: (machineId) =>
_reservedReservation.copyWith(machineId: machineId),
);
final container = ProviderContainer(
overrides: [
reservationRemoteDataSourceProvider.overrideWith(
(ref) => reservationDataSource,
),
reservationPenaltyProvider.overrideWith(
FakeReservationPenaltyNotifier.new,
),
homeRemoteDataSourceProvider.overrideWith(
(ref) => FakeHomeRemoteDataSource(
machineStatusLoader: () async =>
const MachineStatusResponse(machines: [], totalCount: 0),
),
),
],
);
addTearDown(container.dispose);

final notifier = container.read(reservationActionProvider.notifier);
// 83 이 아직 진행 중인 상태에서 84 를 누른다.
final first = notifier.reserve(machineId: 83);
final second = notifier.reserve(machineId: 84);
final results = await Future.wait([first, second]);

expect(reservationDataSource.createdMachineIds, [83, 84]);
expect(results[0]?.machineId, 83);
// 키가 없으면 84 호출이 83 의 결과를 그대로 받아 성공으로 처리된다.
expect(results[1]?.machineId, 84);
});

test('#261: 같은 기기를 연달아 누르면 요청은 한 번만 나간다', () async {
final reservationDataSource = FakeReservationRemoteDataSource();
final container = ProviderContainer(
overrides: [
reservationRemoteDataSourceProvider.overrideWith(
(ref) => reservationDataSource,
),
reservationPenaltyProvider.overrideWith(
FakeReservationPenaltyNotifier.new,
),
homeRemoteDataSourceProvider.overrideWith(
(ref) => FakeHomeRemoteDataSource(
machineStatusLoader: () async =>
const MachineStatusResponse(machines: [], totalCount: 0),
),
),
],
);
addTearDown(container.dispose);

final notifier = container.read(reservationActionProvider.notifier);
final results = await Future.wait([
notifier.reserve(machineId: 83),
notifier.reserve(machineId: 83),
]);

expect(reservationDataSource.createdMachineIds, [83]);
expect(results[0], results[1]);
});

test('#261: 다른 예약을 연달아 취소하면 각각 별도 요청이 나간다', () async {
final reservationDataSource = FakeReservationRemoteDataSource();
final container = ProviderContainer(
overrides: [
reservationRemoteDataSourceProvider.overrideWith(
(ref) => reservationDataSource,
),
reservationPenaltyProvider.overrideWith(
FakeReservationPenaltyNotifier.new,
),
homeRemoteDataSourceProvider.overrideWith(
(ref) => FakeHomeRemoteDataSource(
machineStatusLoader: () async =>
const MachineStatusResponse(machines: [], totalCount: 0),
),
),
],
);
addTearDown(container.dispose);

final notifier = container.read(reservationActionProvider.notifier);
await Future.wait([
notifier.cancel(reservationId: 114),
notifier.cancel(reservationId: 115),
]);

expect(reservationDataSource.cancelledIds, [114, 115]);
});

test('예약 전 조회 결과 이미 예약된 기기면 요청을 보내지 않고 예외를 담는다', () async {
final reservationDataSource = FakeReservationRemoteDataSource();
final container = ProviderContainer(
Expand Down
Loading