Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
31 changes: 25 additions & 6 deletions Modules/Sources/Networking/Model/Bookings/Booking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable {
public let allDay: Bool
public let cost: String
public let customerID: Int64
public let dateCreated: Date
public let dateModified: Date
public let dateCreated: Date?
public let dateModified: Date?
public let endDate: Date
public let googleCalendarEventID: String?
public let orderID: Int64
Expand Down Expand Up @@ -40,8 +40,8 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable {
allDay: Bool,
cost: String,
customerID: Int64,
dateCreated: Date,
dateModified: Date,
dateCreated: Date?,
dateModified: Date?,
endDate: Date,
googleCalendarEventID: String?,
orderID: Int64,
Expand Down Expand Up @@ -95,8 +95,27 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable {
alternativeTypes: [.decimal(transform: { NSDecimalNumber(decimal: $0).stringValue })]) ?? ""

let customerID = try container.decode(Int64.self, forKey: .customerID)
let dateCreated = Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .dateCreated))
let dateModified = Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .dateModified))

let dateCreated: Date?
if let dateCreatedValue = try container.decodeIfPresent(
Double.self,
forKey: .dateCreated
) {
dateCreated = Date(timeIntervalSince1970: dateCreatedValue)
} else {
dateCreated = nil
}

let dateModified: Date?
if let dateModifiedValue = try container.decodeIfPresent(
Double.self,
forKey: .dateModified
) {
dateModified = Date(timeIntervalSince1970: dateModifiedValue)
} else {
dateModified = nil
}

let endDate = Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .endDate))
let googleCalendarEventID = try container.decodeIfPresent(String.self, forKey: .googleCalendarEventID)
let orderID = try container.decode(Int64.self, forKey: .orderID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ extension Storage.Booking: ReadOnlyConvertible {
allDay = booking.allDay
cost = booking.cost
customerID = booking.customerID
dateCreated = booking.dateCreated
dateModified = booking.dateModified
dateCreated = booking.dateCreated ?? dateCreated
dateModified = booking.dateModified ?? dateModified
Comment on lines +20 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Are you sure we want to keep the old dates here? If so, please leave a comment in the code for why that's necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in b3991c7

endDate = booking.endDate
googleCalendarEventID = booking.googleCalendarEventID
orderID = booking.orderID
Expand Down
44 changes: 41 additions & 3 deletions Modules/Sources/Yosemite/Stores/BookingStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,39 @@ private extension BookingStore {
siteID: siteID,
bookingID: bookingID,
statusKey: status
) { _ in
//TODO: - booking status remote update + rollback status in case of error
onCompletion(nil)
) { [weak self] previousStatusKey in
guard let self else {
return onCompletion(UpdateBookingStatusError.undefinedState)
}

Task {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Is it necessary to trigger the completion closure on the main thread with @MainActor here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 78de80e

do {
if let remoteBooking = try await self.remote.updateBooking(
from: siteID,
bookingID: bookingID,
attendanceStatus: status
) {
await self.upsertStoredBookingsInBackground(
readOnlyBookings: [remoteBooking],
readOnlyOrders: [],
siteID: siteID
)

onCompletion(nil)
} else {
return onCompletion(UpdateBookingStatusError.missingRemoteBooking)
}
} catch {
/// Revert Optimistic Update
self.updateBookingAttendanceStatusLocally(
siteID: siteID,
bookingID: bookingID,
statusKey: previousStatusKey
) { _ in
onCompletion(error)
}
}
}
}
}

Expand Down Expand Up @@ -409,3 +439,11 @@ private extension BookingStore {
}
}
}


// MARK: - Errors
//
private enum UpdateBookingStatusError: Error {
case undefinedState
case missingRemoteBooking
}
19 changes: 19 additions & 0 deletions Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,23 @@ struct BookingsRemoteTests {
_ = try await remote.fetchResource(resourceID: 22, siteID: sampleSiteID)
}
}

@Test func test_updateBooking_ignores_nil_dates_in_response() async throws {
// Given
let remote = BookingsRemote(network: network)
let bookingID: Int64 = 206
network.simulateResponse(requestUrlSuffix: "bookings/\(bookingID)", filename: "booking-no-create-update-dates")

// When
let booking = try await remote.updateBooking(
from: sampleSiteID,
bookingID: bookingID,
attendanceStatus: .noShow,
)

// Then
#expect(booking?.dateCreated == nil)
#expect(booking?.dateModified == nil)
#expect(booking?.id == bookingID)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"data": {
"id": 206,
"start": 1761238800,
"end": 1761242400,
"all_day": false,
"status": "cancelled",
"attendance_status": "no-show",
"cost": "35.00",
"currency": "USD",
"customer_id": 5,
"product_id": 23,
"resource_id": 19,
"google_calendar_event_id": "0",
"order_id": 205,
"order_item_id": 21,
"parent_id": 0,
"person_counts": [],
"local_timezone": "",
"note": "edited note"
}
}
10 changes: 9 additions & 1 deletion Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ final class MockBookingsRemote: BookingsRemoteProtocol {
private var loadAllBookingsResult: Result<[Booking], Error>?
private var loadBookingResult: Result<Booking?, Error>?
private var fetchResourceResult: Result<BookingResource?, Error>?
private var updateBookingResult: Result<Booking?, Error>?

func whenLoadingAllBookings(thenReturn result: Result<[Booking], Error>) {
loadAllBookingsResult = result
Expand All @@ -16,6 +17,10 @@ final class MockBookingsRemote: BookingsRemoteProtocol {
loadBookingResult = result
}

func whenUpdatingBooking(thenReturn result: Result<Booking?, Error>) {
updateBookingResult = result
}

func whenFetchingResource(thenReturn result: Result<BookingResource?, Error>) {
fetchResourceResult = result
}
Expand Down Expand Up @@ -48,6 +53,9 @@ final class MockBookingsRemote: BookingsRemoteProtocol {
}

func updateBooking(from siteID: Int64, bookingID: Int64, attendanceStatus: Networking.BookingAttendanceStatus) async throws -> Networking.Booking? {
return nil
guard let result = updateBookingResult else {
throw NetworkError.timeout()
}
return try result.get()
}
}
119 changes: 119 additions & 0 deletions Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import Testing
@testable import Networking
@testable import Storage
Expand Down Expand Up @@ -618,6 +619,124 @@ struct BookingStoreTests {
#expect(orderInfo.statusKey == "processing")
}

// MARK: - performUpdateBookingAttendanceStatus

@Test func performUpdateBookingAttendanceStatus_updates_localBooking() async throws {
// Given
let booking = Booking.fake().copy(
siteID: sampleSiteID,
bookingID: 1,
attendanceStatusKey: BookingAttendanceStatus.booked.rawValue
)
storeBooking(booking)

let remoteBooking = booking.copy(attendanceStatusKey: BookingAttendanceStatus.checkedIn.rawValue)
remote.whenUpdatingBooking(thenReturn: .success(remoteBooking))
let store = BookingStore(dispatcher: Dispatcher(),
storageManager: storageManager,
network: network,
remote: remote,
ordersRemote: ordersRemote)

// When
let error = await withCheckedContinuation { continuation in
store.onAction(
BookingAction.updateBookingAttendanceStatus(
siteID: sampleSiteID,
bookingID: 1,
status: .checkedIn,
onCompletion: { error in
continuation.resume(returning: error)
}
)
)
}

// Then
#expect(error == nil)
let storedBooking = try #require(viewStorage.loadBooking(siteID: sampleSiteID, bookingID: 1))
#expect(storedBooking.attendanceStatusKey == BookingAttendanceStatus.checkedIn.rawValue)
}

@Test func performUpdateBookingAttendanceStatus_keeps_existing_create_and_update_dates() async throws {
// Given
let date = Date(timeIntervalSince1970: 0)
let booking = Booking.fake().copy(
siteID: sampleSiteID,
bookingID: 1,
dateCreated: date,
dateModified: date
)
storeBooking(booking)

let remoteBooking = booking.copy(
dateCreated: nil,
dateModified: nil
)
remote.whenUpdatingBooking(thenReturn: .success(remoteBooking))
let store = BookingStore(dispatcher: Dispatcher(),
storageManager: storageManager,
network: network,
remote: remote,
ordersRemote: ordersRemote)

// When
let error = await withCheckedContinuation { continuation in
store.onAction(
BookingAction.updateBookingAttendanceStatus(
siteID: sampleSiteID,
bookingID: 1,
status: .checkedIn,
onCompletion: { error in
continuation.resume(returning: error)
}
)
)
}

// Then
#expect(error == nil)
let storedBooking = try #require(viewStorage.loadBooking(siteID: sampleSiteID, bookingID: 1))
#expect(storedBooking.dateCreated == date)
#expect(storedBooking.dateModified == date)
}

@Test func performUpdateBookingAttendanceStatus_reverts_old_status_on_error() async throws {
// Given
let booking = Booking.fake().copy(
siteID: sampleSiteID,
bookingID: 1,
attendanceStatusKey: BookingAttendanceStatus.booked.rawValue
)
storeBooking(booking)

remote.whenUpdatingBooking(thenReturn: .failure(NetworkError.timeout()))
let store = BookingStore(dispatcher: Dispatcher(),
storageManager: storageManager,
network: network,
remote: remote,
ordersRemote: ordersRemote)

// When
let error = await withCheckedContinuation { continuation in
store.onAction(
BookingAction.updateBookingAttendanceStatus(
siteID: sampleSiteID,
bookingID: 1,
status: .checkedIn,
onCompletion: { error in
continuation.resume(returning: error)
}
)
)
}

// Then
#expect(error != nil)
let storedBooking = try #require(viewStorage.loadBooking(siteID: sampleSiteID, bookingID: 1))
#expect(storedBooking.attendanceStatusKey == BookingAttendanceStatus.booked.rawValue)
}

// MARK: - orderInfo Storage Tests

@Test func synchronizeBookings_stores_complete_orderInfo_with_all_nested_properties() async throws {
Expand Down
Loading