Skip to content

Commit 29fbd68

Browse files
[Bookings] Update attendance status remotely (#16280)
2 parents 8977b5b + 1d26152 commit 29fbd68

File tree

9 files changed

+364
-30
lines changed

9 files changed

+364
-30
lines changed

Modules/Sources/Networking/Model/Bookings/Booking.swift

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable {
99
public let allDay: Bool
1010
public let cost: String
1111
public let customerID: Int64
12-
public let dateCreated: Date
13-
public let dateModified: Date
12+
public let dateCreated: Date?
13+
public let dateModified: Date?
1414
public let endDate: Date
1515
public let googleCalendarEventID: String?
1616
public let orderID: Int64
@@ -40,8 +40,8 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable {
4040
allDay: Bool,
4141
cost: String,
4242
customerID: Int64,
43-
dateCreated: Date,
44-
dateModified: Date,
43+
dateCreated: Date?,
44+
dateModified: Date?,
4545
endDate: Date,
4646
googleCalendarEventID: String?,
4747
orderID: Int64,
@@ -95,8 +95,27 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable {
9595
alternativeTypes: [.decimal(transform: { NSDecimalNumber(decimal: $0).stringValue })]) ?? ""
9696

9797
let customerID = try container.decode(Int64.self, forKey: .customerID)
98-
let dateCreated = Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .dateCreated))
99-
let dateModified = Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .dateModified))
98+
99+
let dateCreated: Date?
100+
if let dateCreatedValue = try container.decodeIfPresent(
101+
Double.self,
102+
forKey: .dateCreated
103+
) {
104+
dateCreated = Date(timeIntervalSince1970: dateCreatedValue)
105+
} else {
106+
dateCreated = nil
107+
}
108+
109+
let dateModified: Date?
110+
if let dateModifiedValue = try container.decodeIfPresent(
111+
Double.self,
112+
forKey: .dateModified
113+
) {
114+
dateModified = Date(timeIntervalSince1970: dateModifiedValue)
115+
} else {
116+
dateModified = nil
117+
}
118+
100119
let endDate = Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .endDate))
101120
let googleCalendarEventID = try container.decodeIfPresent(String.self, forKey: .googleCalendarEventID)
102121
let orderID = try container.decode(Int64.self, forKey: .orderID)

Modules/Sources/Yosemite/Model/Booking/Booking+ReadOnlyConvertible.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@ extension Storage.Booking: ReadOnlyConvertible {
1313
allDay = booking.allDay
1414
cost = booking.cost
1515
customerID = booking.customerID
16-
dateCreated = booking.dateCreated
17-
dateModified = booking.dateModified
16+
17+
/// Falling to back to existing values in case if new values are absent
18+
/// Booking returned when sending a `PUT` request to `bookings/{booking_id}`
19+
/// doesn't contain `date_created` and `date_modified` values.
20+
dateCreated = booking.dateCreated ?? dateCreated
21+
dateModified = booking.dateModified ?? dateModified
22+
1823
endDate = booking.endDate
1924
googleCalendarEventID = booking.googleCalendarEventID
2025
orderID = booking.orderID

Modules/Sources/Yosemite/Stores/BookingStore.swift

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,9 +291,39 @@ private extension BookingStore {
291291
siteID: siteID,
292292
bookingID: bookingID,
293293
statusKey: status
294-
) { _ in
295-
//TODO: - booking status remote update + rollback status in case of error
296-
onCompletion(nil)
294+
) { [weak self] previousStatusKey in
295+
guard let self else {
296+
return onCompletion(UpdateBookingStatusError.undefinedState)
297+
}
298+
299+
Task { @MainActor in
300+
do {
301+
if let remoteBooking = try await self.remote.updateBooking(
302+
from: siteID,
303+
bookingID: bookingID,
304+
attendanceStatus: status
305+
) {
306+
await self.upsertStoredBookingsInBackground(
307+
readOnlyBookings: [remoteBooking],
308+
readOnlyOrders: [],
309+
siteID: siteID
310+
)
311+
312+
onCompletion(nil)
313+
} else {
314+
return onCompletion(UpdateBookingStatusError.missingRemoteBooking)
315+
}
316+
} catch {
317+
/// Revert Optimistic Update
318+
self.updateBookingAttendanceStatusLocally(
319+
siteID: siteID,
320+
bookingID: bookingID,
321+
statusKey: previousStatusKey
322+
) { _ in
323+
onCompletion(error)
324+
}
325+
}
326+
}
297327
}
298328
}
299329

@@ -443,3 +473,11 @@ private extension BookingStore {
443473
}
444474
}
445475
}
476+
477+
478+
// MARK: - Errors
479+
//
480+
private enum UpdateBookingStatusError: Error {
481+
case undefinedState
482+
case missingRemoteBooking
483+
}

Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,25 @@ struct BookingsRemoteTests {
124124
}
125125
}
126126

127+
@Test func test_updateBooking_ignores_nil_dates_in_response() async throws {
128+
// Given
129+
let remote = BookingsRemote(network: network)
130+
let bookingID: Int64 = 206
131+
network.simulateResponse(requestUrlSuffix: "bookings/\(bookingID)", filename: "booking-no-create-update-dates")
132+
133+
// When
134+
let booking = try await remote.updateBooking(
135+
from: sampleSiteID,
136+
bookingID: bookingID,
137+
attendanceStatus: .noShow,
138+
)
139+
140+
// Then
141+
#expect(booking?.dateCreated == nil)
142+
#expect(booking?.dateModified == nil)
143+
#expect(booking?.id == bookingID)
144+
}
145+
127146
@Test func test_fetchResources_properly_returns_parsed_resources() async throws {
128147
// Given
129148
let remote = BookingsRemote(network: network)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"data": {
3+
"id": 206,
4+
"start": 1761238800,
5+
"end": 1761242400,
6+
"all_day": false,
7+
"status": "cancelled",
8+
"attendance_status": "no-show",
9+
"cost": "35.00",
10+
"currency": "USD",
11+
"customer_id": 5,
12+
"product_id": 23,
13+
"resource_id": 19,
14+
"google_calendar_event_id": "0",
15+
"order_id": 205,
16+
"order_item_id": 21,
17+
"parent_id": 0,
18+
"person_counts": [],
19+
"local_timezone": "",
20+
"note": "edited note"
21+
}
22+
}

Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ final class MockBookingsRemote: BookingsRemoteProtocol {
77
private var loadAllBookingsResult: Result<[Booking], Error>?
88
private var loadBookingResult: Result<Booking?, Error>?
99
private var fetchResourceResult: Result<BookingResource?, Error>?
10+
private var updateBookingResult: Result<Booking?, Error>?
1011
private var fetchResourcesResult: Result<[BookingResource], Error>?
1112

1213
func whenLoadingAllBookings(thenReturn result: Result<[Booking], Error>) {
@@ -17,6 +18,10 @@ final class MockBookingsRemote: BookingsRemoteProtocol {
1718
loadBookingResult = result
1819
}
1920

21+
func whenUpdatingBooking(thenReturn result: Result<Booking?, Error>) {
22+
updateBookingResult = result
23+
}
24+
2025
func whenFetchingResource(thenReturn result: Result<BookingResource?, Error>) {
2126
fetchResourceResult = result
2227
}
@@ -53,7 +58,10 @@ final class MockBookingsRemote: BookingsRemoteProtocol {
5358
}
5459

5560
func updateBooking(from siteID: Int64, bookingID: Int64, attendanceStatus: Networking.BookingAttendanceStatus) async throws -> Networking.Booking? {
56-
return nil
61+
guard let result = updateBookingResult else {
62+
throw NetworkError.timeout()
63+
}
64+
return try result.get()
5765
}
5866

5967
func fetchResources(for siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> [Networking.BookingResource] {

Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Foundation
12
import Testing
23
@testable import Networking
34
@testable import Storage
@@ -624,6 +625,124 @@ struct BookingStoreTests {
624625
#expect(orderInfo.statusKey == "processing")
625626
}
626627

628+
// MARK: - performUpdateBookingAttendanceStatus
629+
630+
@Test func performUpdateBookingAttendanceStatus_updates_localBooking() async throws {
631+
// Given
632+
let booking = Booking.fake().copy(
633+
siteID: sampleSiteID,
634+
bookingID: 1,
635+
attendanceStatusKey: BookingAttendanceStatus.booked.rawValue
636+
)
637+
storeBooking(booking)
638+
639+
let remoteBooking = booking.copy(attendanceStatusKey: BookingAttendanceStatus.checkedIn.rawValue)
640+
remote.whenUpdatingBooking(thenReturn: .success(remoteBooking))
641+
let store = BookingStore(dispatcher: Dispatcher(),
642+
storageManager: storageManager,
643+
network: network,
644+
remote: remote,
645+
ordersRemote: ordersRemote)
646+
647+
// When
648+
let error = await withCheckedContinuation { continuation in
649+
store.onAction(
650+
BookingAction.updateBookingAttendanceStatus(
651+
siteID: sampleSiteID,
652+
bookingID: 1,
653+
status: .checkedIn,
654+
onCompletion: { error in
655+
continuation.resume(returning: error)
656+
}
657+
)
658+
)
659+
}
660+
661+
// Then
662+
#expect(error == nil)
663+
let storedBooking = try #require(viewStorage.loadBooking(siteID: sampleSiteID, bookingID: 1))
664+
#expect(storedBooking.attendanceStatusKey == BookingAttendanceStatus.checkedIn.rawValue)
665+
}
666+
667+
@Test func performUpdateBookingAttendanceStatus_keeps_existing_create_and_update_dates() async throws {
668+
// Given
669+
let date = Date(timeIntervalSince1970: 0)
670+
let booking = Booking.fake().copy(
671+
siteID: sampleSiteID,
672+
bookingID: 1,
673+
dateCreated: date,
674+
dateModified: date
675+
)
676+
storeBooking(booking)
677+
678+
let remoteBooking = booking.copy(
679+
dateCreated: nil,
680+
dateModified: nil
681+
)
682+
remote.whenUpdatingBooking(thenReturn: .success(remoteBooking))
683+
let store = BookingStore(dispatcher: Dispatcher(),
684+
storageManager: storageManager,
685+
network: network,
686+
remote: remote,
687+
ordersRemote: ordersRemote)
688+
689+
// When
690+
let error = await withCheckedContinuation { continuation in
691+
store.onAction(
692+
BookingAction.updateBookingAttendanceStatus(
693+
siteID: sampleSiteID,
694+
bookingID: 1,
695+
status: .checkedIn,
696+
onCompletion: { error in
697+
continuation.resume(returning: error)
698+
}
699+
)
700+
)
701+
}
702+
703+
// Then
704+
#expect(error == nil)
705+
let storedBooking = try #require(viewStorage.loadBooking(siteID: sampleSiteID, bookingID: 1))
706+
#expect(storedBooking.dateCreated == date)
707+
#expect(storedBooking.dateModified == date)
708+
}
709+
710+
@Test func performUpdateBookingAttendanceStatus_reverts_old_status_on_error() async throws {
711+
// Given
712+
let booking = Booking.fake().copy(
713+
siteID: sampleSiteID,
714+
bookingID: 1,
715+
attendanceStatusKey: BookingAttendanceStatus.booked.rawValue
716+
)
717+
storeBooking(booking)
718+
719+
remote.whenUpdatingBooking(thenReturn: .failure(NetworkError.timeout()))
720+
let store = BookingStore(dispatcher: Dispatcher(),
721+
storageManager: storageManager,
722+
network: network,
723+
remote: remote,
724+
ordersRemote: ordersRemote)
725+
726+
// When
727+
let error = await withCheckedContinuation { continuation in
728+
store.onAction(
729+
BookingAction.updateBookingAttendanceStatus(
730+
siteID: sampleSiteID,
731+
bookingID: 1,
732+
status: .checkedIn,
733+
onCompletion: { error in
734+
continuation.resume(returning: error)
735+
}
736+
)
737+
)
738+
}
739+
740+
// Then
741+
#expect(error != nil)
742+
let storedBooking = try #require(viewStorage.loadBooking(siteID: sampleSiteID, bookingID: 1))
743+
#expect(storedBooking.attendanceStatusKey == BookingAttendanceStatus.booked.rawValue)
744+
}
745+
627746
// MARK: - synchronizeResources
628747

629748
@Test func synchronizeResources_returns_false_for_hasNextPage_when_number_of_retrieved_results_is_zero() async throws {

0 commit comments

Comments
 (0)