diff --git a/ios/Buildings/Package.resolved b/ios/Buildings/Package.resolved index fd4f84ab..ba14acd9 100644 --- a/ios/Buildings/Package.resolved +++ b/ios/Buildings/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e3a4e0e2698fbe6672e0ded7860ebef15af35732794cfd07f0ee0026070ddaae", + "originHash" : "ed88130e767ad48818ea802fa72a54f5709a64a1c6534d57824a8bff672941c1", "pins" : [ { "identity" : "bottomsheet", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/avdn-dev/VISOR.git", "state" : { - "revision" : "8281ca20b434afdb5302e272677f62a2ab0f6806", - "version" : "4.0.0" + "revision" : "4ba6dd8d9845c431fca0edeecff2d5efbc105045", + "version" : "8.0.1" } } ], diff --git a/ios/Freerooms/Freerooms/FreeroomsApp.swift b/ios/Freerooms/Freerooms/FreeroomsApp.swift index 8b208816..9e0f77b0 100644 --- a/ios/Freerooms/Freerooms/FreeroomsApp.swift +++ b/ios/Freerooms/Freerooms/FreeroomsApp.swift @@ -65,7 +65,7 @@ struct FreeroomsApp: App { let swiftDataStore = try SwiftDataStore(modelContext: FreeroomsApp.sharedContainer.mainContext) let swiftDataBuildingLoader = LiveSwiftDataBuildingLoader(swiftDataStore: swiftDataStore) - let (roomStatusLoader, buildingRatingLoader, _, _) = makeRemoteLoaders() + let (roomStatusLoader, buildingRatingLoader, _, _, _) = makeRemoteLoaders() let buildingLoader = LiveBuildingLoader( swiftDataBuildingLoader: swiftDataBuildingLoader, @@ -94,7 +94,7 @@ struct FreeroomsApp: App { let swiftDataStore = try SwiftDataStore(modelContext: modelContext) let swiftDataBuildingLoader = LiveSwiftDataBuildingLoader(swiftDataStore: swiftDataStore) - let (roomStatusLoader, buildingRatingLoader, _, _) = makeRemoteLoaders() + let (roomStatusLoader, buildingRatingLoader, _, _, _) = makeRemoteLoaders() let buildingLoader = LiveBuildingLoader( swiftDataBuildingLoader: swiftDataBuildingLoader, @@ -132,7 +132,7 @@ struct FreeroomsApp: App { let swiftDataStore = try SwiftDataStore(modelContext: FreeroomsApp.sharedContainer.mainContext) let swiftDataRoomLoader = LiveSwiftDataRoomLoader(swiftDataStore: swiftDataStore) - let (roomStatusLoader, _, remoteBookingLoader, roomRatingLoader) = makeRemoteLoaders() + let (roomStatusLoader, _, remoteBookingLoader, roomRatingLoader, roomFilterLoader) = makeRemoteLoaders() let roomLoader = LiveRoomLoader( JSONRoomLoader: JSONRoomLoader, @@ -144,7 +144,8 @@ struct FreeroomsApp: App { let roomService = LiveRoomService( roomLoader: roomLoader, roomBookingLoader: roomBookingLoader, - roomRatingLoader: roomRatingLoader) + roomRatingLoader: roomRatingLoader, + roomFilterLoader: roomFilterLoader) let interactor = RoomInteractor(roomService: roomService, locationService: locationService) return LiveRoomViewModel(interactor: interactor) @@ -191,7 +192,8 @@ struct FreeroomsApp: App { roomStatusLoader: LiveRoomStatusLoader, buildingRatingLoader: RemoteBuildingRatingLoader, remoteBookingLoader: LiveRemoteRoomBookingLoader, - roomRatingLoader: LiveRoomRatingLoader) + roomRatingLoader: LiveRoomRatingLoader, + roomFilterLoader: LiveFilterRoomLoader) { let httpClient = makeHTTPClient() let (stagingURL, productionURL) = makeBaseURLs() @@ -200,7 +202,8 @@ struct FreeroomsApp: App { let buildingRatingLoader = RemoteBuildingRatingLoader(client: httpClient, baseURL: productionURL) let remoteBookingLoader = LiveRemoteRoomBookingLoader(client: httpClient, baseURL: productionURL) let roomRatingLoader = LiveRoomRatingLoader(client: httpClient, baseURL: productionURL) + let roomFilterLoader = LiveFilterRoomLoader(client: httpClient, baseURL: productionURL) - return (roomStatusLoader, buildingRatingLoader, remoteBookingLoader, roomRatingLoader) + return (roomStatusLoader, buildingRatingLoader, remoteBookingLoader, roomRatingLoader, roomFilterLoader) } } diff --git a/ios/Networking/Package.resolved b/ios/Networking/Package.resolved new file mode 100644 index 00000000..758ce739 --- /dev/null +++ b/ios/Networking/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "f0ce5f7cad2bf6c89cecc6f341a9d7a38fc36bf5d4a01cbb228959c36dffa5eb", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "visor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/avdn-dev/VISOR.git", + "state" : { + "revision" : "4ba6dd8d9845c431fca0edeecff2d5efbc105045", + "version" : "8.0.1" + } + } + ], + "version" : 3 +} diff --git a/ios/Networking/Package.swift b/ios/Networking/Package.swift index 74ecc108..a0b37104 100644 --- a/ios/Networking/Package.swift +++ b/ios/Networking/Package.swift @@ -15,12 +15,16 @@ let package = Package( ], dependencies: [ .package(name: "TestingSupport", path: "../TestingSupport"), + .package(url: "https://github.com/avdn-dev/VISOR.git", from: "8.0.0"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "Networking", + dependencies: [ + .product(name: "VISOR", package: "VISOR"), + ], swiftSettings: .defaultSettings), .target(name: "NetworkingTestUtils", dependencies: ["Networking"], swiftSettings: .defaultSettings), .testTarget( diff --git a/ios/Networking/Sources/Networking/HTTPClient.swift b/ios/Networking/Sources/Networking/HTTPClient.swift index 767e525d..8e41db9e 100644 --- a/ios/Networking/Sources/Networking/HTTPClient.swift +++ b/ios/Networking/Sources/Networking/HTTPClient.swift @@ -6,13 +6,16 @@ // import Foundation +import VISOR // MARK: - HTTPClient +public typealias HTTPClientResult = Swift.Result<(Data, HTTPURLResponse), Error> -public protocol HTTPClient { - typealias Result = Swift.Result<(Data, HTTPURLResponse), Error> +// MARK: - HTTPClient - func get(from url: URL) async -> Result +@Spyable +public protocol HTTPClient { + func get(from url: URL) async -> HTTPClientResult } // MARK: - HTTPClientError diff --git a/ios/Networking/Sources/Networking/URLSessionHTTPClient.swift b/ios/Networking/Sources/Networking/URLSessionHTTPClient.swift index 3655d388..3532c28b 100644 --- a/ios/Networking/Sources/Networking/URLSessionHTTPClient.swift +++ b/ios/Networking/Sources/Networking/URLSessionHTTPClient.swift @@ -19,7 +19,7 @@ public struct URLSessionHTTPClient: HTTPClient, Sendable { // MARK: Public - public func get(from url: URL) async -> HTTPClient.Result { + public func get(from url: URL) async -> HTTPClientResult { do { let (data, urlResponse) = try await session.data(from: url) guard let httpUrlResponse = urlResponse as? HTTPURLResponse else { diff --git a/ios/Networking/Sources/NetworkingTestUtils/MockHTTPClient.swift b/ios/Networking/Sources/NetworkingTestUtils/MockHTTPClient.swift index 20f7f529..1499be6b 100644 --- a/ios/Networking/Sources/NetworkingTestUtils/MockHTTPClient.swift +++ b/ios/Networking/Sources/NetworkingTestUtils/MockHTTPClient.swift @@ -26,7 +26,7 @@ public class MockHTTPClient: HTTPClient { stubbedError = NSError(domain: "test", code: 0) } - public func get(from url: URL) async -> HTTPClient.Result { + public func get(from url: URL) async -> HTTPClientResult { if let error = stubbedError { return .failure(error) } diff --git a/ios/Networking/Tests/NetworkingTests/NetworkCodableLoaderTests.swift b/ios/Networking/Tests/NetworkingTests/NetworkCodableLoaderTests.swift index b8d206ce..12c94dd8 100644 --- a/ios/Networking/Tests/NetworkingTests/NetworkCodableLoaderTests.swift +++ b/ios/Networking/Tests/NetworkingTests/NetworkCodableLoaderTests.swift @@ -175,7 +175,7 @@ private final class MockHTTPClient: HTTPClient { returnedStatusCode = 200 } - func get(from url: URL) async -> HTTPClient.Result { + func get(from url: URL) async -> HTTPClientResult { networkCallCount += 1 if let returnedStringData { diff --git a/ios/Networking/Tests/NetworkingTests/URLSessionHTTPClientTests.swift b/ios/Networking/Tests/NetworkingTests/URLSessionHTTPClientTests.swift index e1e56084..d5334916 100644 --- a/ios/Networking/Tests/NetworkingTests/URLSessionHTTPClientTests.swift +++ b/ios/Networking/Tests/NetworkingTests/URLSessionHTTPClientTests.swift @@ -87,7 +87,7 @@ struct URLSessionHTTPClientTests { // MARK: Private - private func expect(_ res: HTTPClient.Result, toThrow httpClientError: HTTPClientError) { + private func expect(_ res: HTTPClientResult, toThrow httpClientError: HTTPClientError) { switch res { case .failure(let error): #expect(error as? HTTPClientError == httpClientError) @@ -96,7 +96,7 @@ struct URLSessionHTTPClientTests { } } - private func expect(_ res: HTTPClient.Result, toFetch data: Data, and urlResponse: URLResponse) { + private func expect(_ res: HTTPClientResult, toFetch data: Data, and urlResponse: URLResponse) { switch res { case .success(let (data, httpURLResponse)): #expect(data == data && httpURLResponse == urlResponse) diff --git a/ios/Persistence/Package.resolved b/ios/Persistence/Package.resolved new file mode 100644 index 00000000..7882262b --- /dev/null +++ b/ios/Persistence/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "b5d70099250c7bfb4bc093f0708db577cd255ad89045ff939ddbf816d7215dc5", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "visor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/avdn-dev/VISOR.git", + "state" : { + "revision" : "4ba6dd8d9845c431fca0edeecff2d5efbc105045", + "version" : "8.0.1" + } + } + ], + "version" : 3 +} diff --git a/ios/Rooms/Sources/RoomModels/FilterRoomOptions.swift b/ios/Rooms/Sources/RoomModels/FilterRoomOptions.swift new file mode 100644 index 00000000..122bc320 --- /dev/null +++ b/ios/Rooms/Sources/RoomModels/FilterRoomOptions.swift @@ -0,0 +1,48 @@ +// +// FilterRoomOptions.swift +// Rooms +// +// Created by Yanlin Li on 26/4/2026. +// + +import Foundation + +public struct FilterRoomOptions: Equatable, Sendable { + + // MARK: Lifecycle + + public init( + dateTime: String? = nil, + startTime: String? = nil, + endTime: String? = nil, + buildingId: String? = nil, + capacity: Int? = nil, + duration: Int? = nil, + usage: String? = nil, + location: String? = nil, + sortedBySpecificSchoolId: Bool = false) + { + self.dateTime = dateTime + self.startTime = startTime + self.endTime = endTime + self.buildingId = buildingId + self.capacity = capacity + self.duration = duration + self.usage = usage + self.location = location + self.sortedBySpecificSchoolId = sortedBySpecificSchoolId + } + + // MARK: Public + + public let dateTime: String? + public let startTime: String? + public let endTime: String? + public let buildingId: String? + public let capacity: Int? + public let duration: Int? + public let usage: String? + public let location: String? + public let sortedBySpecificSchoolId: Bool + +} diff --git a/ios/Rooms/Sources/RoomModels/RemoteFilterRoom.swift b/ios/Rooms/Sources/RoomModels/RemoteFilterRoom.swift new file mode 100644 index 00000000..bf45539b --- /dev/null +++ b/ios/Rooms/Sources/RoomModels/RemoteFilterRoom.swift @@ -0,0 +1,25 @@ +// +// RemoteFilterRoom.swift +// Rooms +// +// Created by Yanlin Li on 26/4/2026. +// + +import Foundation + +public typealias RemoteFilterRoomMap = [String: RemoteFilterRoomValue] + +// MARK: - RemoteFilterRoomValue + +/// Represents the details of a room returned from the remote API when fetching filtered rooms. +public struct RemoteFilterRoomValue: Codable, Equatable, Sendable { + public let status: String + public let endTime: String + public let name: String + + enum CodingKeys: String, CodingKey { + case status + case endTime = "endtime" + case name + } +} diff --git a/ios/Rooms/Sources/RoomServices/FilterRoomLoader.swift b/ios/Rooms/Sources/RoomServices/FilterRoomLoader.swift new file mode 100644 index 00000000..6b8ad0d9 --- /dev/null +++ b/ios/Rooms/Sources/RoomServices/FilterRoomLoader.swift @@ -0,0 +1,121 @@ +// +// FilterRoomLoader.swift +// Rooms +// +// Created by Yanlin Li on 17/4/2026. +// + +import Foundation +import Networking +import RoomModels +import VISOR + +// MARK: - FilterRoomLoaderError + +public enum FilterRoomLoaderError: Error, Equatable { + case connectivity + case invalidData + case invalidURL +} + +// MARK: - FilterRoomLoader + +@Spyable +@Stubbable +public protocol FilterRoomLoader { + + /// Fetches rooms that match the provided filter conditions. + /// + /// Use this Loader to retrieve rooms based on booking time, building, + /// capacity, duration, usage type, and location. + /// + /// - Parameters: + /// - dateTime: The date to search for available rooms. + /// - startTime: The requested booking start time. + /// - endTime: The requested booking end time. + /// - buildingId: The identifier of the building to filter rooms by. + /// - capacity: The minimum room capacity required. + /// - duration: The requested booking duration. + /// - usage: The intended room usage type. + /// - location: The location or campus area to filter rooms by. + /// - SortedBySpecificSchoolId: A flag used to filter by a specific school (non-CATS) + /// + /// - Returns: A result containing either the filtered rooms or a + /// `FilterRoomLoaderError` if the request fails. + func fetchFilteredRooms(options: FilterRoomOptions) + async -> Result<[String], FilterRoomLoaderError> +} + +// MARK: - LiveFilterRoomLoader + +/// Provides the current filtered room +public final class LiveFilterRoomLoader: FilterRoomLoader { + + // MARK: Lifecycle + + public init(client: HTTPClient, baseURL: URL, endpointPath: String = "api/rooms/search") { + self.client = client + self.baseURL = baseURL + self.endpointPath = endpointPath + } + + // MARK: Public + + public func fetchFilteredRooms(options: FilterRoomOptions) + async -> Result<[String], FilterRoomLoaderError> + { + // Construct the search URL with query parameters based on the provided filter conditions + guard + let url = makeSearchRoomsURL(options: options) + else { + return .failure(.invalidURL) + } + + // Create a NetworkCodableLoader to perform the GET request and decode the response into an array of RemoteFilterRoomMap + let loader = NetworkCodableLoader(client: client, url: url) + + switch await loader.fetch() { + case .success(let response): + let roomIds = Array(response.keys) + + return .success(roomIds) + + case .failure: + return .failure(.connectivity) + } + } + + // MARK: Private + + private let client: HTTPClient + private let baseURL: URL + private let endpointPath: String + + /// Helper method to construct the search URL with query parameters + private func makeSearchRoomsURL(options: FilterRoomOptions) -> URL? { + // Construct the base endpoint URL and append query parameters for filtering rooms + guard + let baseEndpointURL = URL(string: endpointPath, relativeTo: baseURL), + var components = URLComponents(url: baseEndpointURL, resolvingAgainstBaseURL: true) + else { + return nil + } + + // Add query items for each filter parameter, using empty strings for nil values and converting boolean to "true"/"false" + components.queryItems = [ + URLQueryItem(name: "datetime", value: options.dateTime ?? ""), + URLQueryItem(name: "startTime", value: options.startTime ?? ""), + URLQueryItem(name: "endTime", value: options.endTime ?? ""), + URLQueryItem(name: "buildingId", value: options.buildingId ?? ""), + URLQueryItem(name: "capacity", value: options.capacity.map(String.init) ?? ""), + URLQueryItem(name: "duration", value: options.duration.map(String.init) ?? ""), + URLQueryItem(name: "usage", value: options.usage ?? ""), + URLQueryItem(name: "location", value: options.location ?? ""), + URLQueryItem(name: "id", value: options.sortedBySpecificSchoolId ? "true" : "false"), + ] + + // Return the fully constructed URL with query parameters + return components.url + } + +} diff --git a/ios/Rooms/Sources/RoomServices/RoomService.swift b/ios/Rooms/Sources/RoomServices/RoomService.swift index 0c2bed39..5dacb0a1 100644 --- a/ios/Rooms/Sources/RoomServices/RoomService.swift +++ b/ios/Rooms/Sources/RoomServices/RoomService.swift @@ -45,6 +45,8 @@ public protocol RoomService { func getRooms(buildingId: String) async -> GetRoomResult func getRoomBookings(roomID: String) async -> GetRoomBookingsResult func getRoomRating(roomID: String) async -> GetRoomRatingResult + func getFilterRooms(options: FilterRoomOptions) + async -> GetRoomResult } // MARK: - LiveRoomService @@ -53,14 +55,43 @@ public final class LiveRoomService: RoomService { // MARK: Lifecycle - public init(roomLoader: any RoomLoader, roomBookingLoader: any RoomBookingLoader, roomRatingLoader: any RoomRatingLoader) { + public init( + roomLoader: any RoomLoader, + roomBookingLoader: any RoomBookingLoader, + roomRatingLoader: any RoomRatingLoader, + roomFilterLoader: any FilterRoomLoader) + { self.roomLoader = roomLoader self.roomBookingLoader = roomBookingLoader self.roomRatingLoader = roomRatingLoader + self.roomFilterLoader = roomFilterLoader } // MARK: Public + public func getFilterRooms(options: FilterRoomOptions) + async -> GetRoomResult + { + // TODO: add a guard here later + var rooms: [Room] = [] + switch await roomLoader.fetch() { + case .success(let response): + rooms = response + case .failure: + return .failure(.connectivity) + } + + switch await roomFilterLoader.fetchFilteredRooms(options: options) { + case .success(let response): + let filteredRooms = rooms.filter { response.contains($0.id) } + + return .success(filteredRooms) + + case .failure: + return .failure(.connectivity) + } + } + public func getRooms(buildingId: String) async -> GetRoomResult { // Validate input guard !buildingId.isEmpty else { @@ -114,6 +145,7 @@ public final class LiveRoomService: RoomService { private var roomLoader: any RoomLoader private var roomBookingLoader: any RoomBookingLoader private var roomRatingLoader: any RoomRatingLoader + private var roomFilterLoader: any FilterRoomLoader } // MARK: - PreviewRoomService @@ -150,4 +182,10 @@ public final class PreviewRoomService: RoomService { overallRating: 4.0, averageRating: AverageRating(cleanliness: 5.0, location: 5.0, quietness: 4.0))) } + + public func getFilterRooms(options _: FilterRoomOptions) + async -> GetRoomResult + { + .success([Room.exampleOne, Room.exampleTwo]) + } } diff --git a/ios/Rooms/Tests/RoomsTests/FilterRoomLoaderTests.swift b/ios/Rooms/Tests/RoomsTests/FilterRoomLoaderTests.swift new file mode 100644 index 00000000..d309d297 --- /dev/null +++ b/ios/Rooms/Tests/RoomsTests/FilterRoomLoaderTests.swift @@ -0,0 +1,196 @@ +// +// FilterRoomLoaderTests.swift +// Rooms +// +// Created by Yanlin Li on 26/4/2026. +// + +import Foundation +import Networking +import RoomModels +import RoomServices +import RoomTestUtils +import Testing + +@Suite +struct FilterRoomLoaderTests { + + // MARK: Lifecycle + + init() { + client = SpyHTTPClient() + sut = LiveFilterRoomLoader( + client: client, + baseURL: URL(string: "https://freerooms.devsoc.app")!) + } + + // MARK: Internal + + @Test + func fetchFilteredRooms_success_returnsRoomIdsFromResponseMap() async throws { + client.getReturnValue = HTTPClientResult.success(makeHTTPResponse( + route: "/api/rooms/search", + json: """ + { + "K-G27-108": { + "status": "free", + "endtime": "", + "name": "AGSM 108 Ex Phys Motor Control" + }, + "K-G27-109": { + "status": "free", + "endtime": "", + "name": "AGSM 109 Exercise Physiology" + } + } + """)) + + let result = await sut.fetchFilteredRooms(options: FilterRoomOptions( + dateTime: "2026-04-17T06:07:40.097Z", + startTime: nil, + endTime: nil, + buildingId: nil, + capacity: nil, + duration: 120, + usage: nil, + location: nil, + sortedBySpecificSchoolId: false)) + + switch result { + case .success(let roomIds): + #expect(Set(roomIds) == Set(["K-G27-108", "K-G27-109"])) + + case .failure(let error): + Issue.record("Expected success, got failure: \(error)") + } + } + + @Test + func fetchFilteredRooms_sendsFilterValuesAsQueryParameters() async throws { + client.getReturnValue = HTTPClientResult.success(makeHTTPResponse( + route: "/api/rooms/search", + json: """ + { + "K-G27-108": { + "status": "free", + "endtime": "", + "name": "AGSM 108 Ex Phys Motor Control" + } + } + """)) + + _ = await sut.fetchFilteredRooms(options: FilterRoomOptions( + dateTime: "2026-04-17T06:07:40.097Z", + startTime: "09:00", + endTime: "11:00", + buildingId: "K-G27", + capacity: 20, + duration: 120, + usage: "study", + location: "Kensington", + sortedBySpecificSchoolId: true)) + + let url = try #require(client.getReceivedUrl) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: true)) + let queryItems = Dictionary( + uniqueKeysWithValues: (components.queryItems ?? []).map { + ($0.name, $0.value ?? "") + }) + + #expect(components.path == "/api/rooms/search") + + // These should match the backend spec exactly. + #expect(queryItems["datetime"] == "2026-04-17T06:07:40.097Z") + #expect(queryItems["startTime"] == "09:00") + #expect(queryItems["endTime"] == "11:00") + #expect(queryItems["buildingId"] == "K-G27") + #expect(queryItems["capacity"] == "20") + #expect(queryItems["duration"] == "120") + #expect(queryItems["usage"] == "study") + #expect(queryItems["location"] == "Kensington") + #expect(queryItems["id"] == "true") + } + + @Test + func fetchFilteredRooms_withNilFilters_sendsEmptyQueryValues() async throws { + client.getReturnValue = HTTPClientResult.success(makeHTTPResponse( + route: "/api/rooms/search", + json: """ + {} + """)) + + _ = await sut.fetchFilteredRooms(options: FilterRoomOptions( + dateTime: nil, + startTime: nil, + endTime: nil, + buildingId: nil, + capacity: nil, + duration: nil, + usage: nil, + location: nil, + sortedBySpecificSchoolId: false)) + + let url = try #require(client.getReceivedUrl) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: true)) + let queryItems = Dictionary( + uniqueKeysWithValues: (components.queryItems ?? []).map { + ($0.name, $0.value ?? "") + }) + + #expect(queryItems["datetime"] == "") + #expect(queryItems["startTime"] == "") + #expect(queryItems["endTime"] == "") + #expect(queryItems["buildingId"] == "") + #expect(queryItems["capacity"] == "") + #expect(queryItems["duration"] == "") + #expect(queryItems["usage"] == "") + #expect(queryItems["location"] == "") + #expect(queryItems["id"] == "false") + } + + @Test + func fetchFilteredRooms_whenClientFails_returnsConnectivityError() async throws { + client.getReturnValue = HTTPClientResult.failure(AnyError()) + + let result = await sut.fetchFilteredRooms(options: FilterRoomOptions( + dateTime: nil, + startTime: nil, + endTime: nil, + buildingId: nil, + capacity: nil, + duration: nil, + usage: nil, + location: nil, + sortedBySpecificSchoolId: false)) + + #expect(result == .failure(.connectivity)) + } + + @Test + func fetchFilteredRooms_whenResponseIsInvalidJSON_returnsConnectivityError() async throws { + client.getReturnValue = HTTPClientResult.success(makeHTTPResponse( + route: "/api/rooms/search", + json: """ + not valid json + """)) + + let result = await sut.fetchFilteredRooms(options: FilterRoomOptions( + dateTime: nil, + startTime: nil, + endTime: nil, + buildingId: nil, + capacity: nil, + duration: nil, + usage: nil, + location: nil, + sortedBySpecificSchoolId: false)) + + #expect(result == .failure(.connectivity)) + } + + // MARK: Private + + private let client: SpyHTTPClient + private let sut: LiveFilterRoomLoader + +} diff --git a/ios/Rooms/Tests/RoomsTests/RoomInteractorTests.swift b/ios/Rooms/Tests/RoomsTests/RoomInteractorTests.swift index 755d5cbb..456a2070 100644 --- a/ios/Rooms/Tests/RoomsTests/RoomInteractorTests.swift +++ b/ios/Rooms/Tests/RoomsTests/RoomInteractorTests.swift @@ -367,7 +367,8 @@ func makeRoomSUT( let roomService = LiveRoomService( roomLoader: stubLoader, roomBookingLoader: roomBookingLoader, - roomRatingLoader: StubRoomRatingLoader()) + roomRatingLoader: StubRoomRatingLoader(), + roomFilterLoader: StubFilterRoomLoader()) return RoomInteractor(roomService: roomService, locationService: locationService) } @@ -380,7 +381,8 @@ func makeRoomSUT(stubLoader: StubRoomLoader) -> RoomInteractor { let roomService = LiveRoomService( roomLoader: stubLoader, roomBookingLoader: roomBookingLoader, - roomRatingLoader: StubRoomRatingLoader()) + roomRatingLoader: StubRoomRatingLoader(), + roomFilterLoader: StubFilterRoomLoader()) return RoomInteractor(roomService: roomService, locationService: locationService) } diff --git a/ios/Rooms/Tests/RoomsTests/RoomSearchInteractorTests.swift b/ios/Rooms/Tests/RoomsTests/RoomSearchInteractorTests.swift index c549c53e..5ca5c2e2 100644 --- a/ios/Rooms/Tests/RoomsTests/RoomSearchInteractorTests.swift +++ b/ios/Rooms/Tests/RoomsTests/RoomSearchInteractorTests.swift @@ -185,7 +185,8 @@ func makeRoomSearchSUT(expect rooms: [Room]) -> RoomSearchAdapter { let roomService = LiveRoomService( roomLoader: stubLoader, roomBookingLoader: roomBookingLoader, - roomRatingLoader: StubRoomRatingLoader()) + roomRatingLoader: StubRoomRatingLoader(), + roomFilterLoader: StubFilterRoomLoader()) let interactor = RoomInteractor( roomService: roomService, locationService: LiveLocationService(locationManager: LiveLocationManager())) diff --git a/ios/Rooms/Tests/RoomsTests/RoomServiceTests.swift b/ios/Rooms/Tests/RoomsTests/RoomServiceTests.swift index 5cdf7b6e..1930e6e5 100644 --- a/ios/Rooms/Tests/RoomsTests/RoomServiceTests.swift +++ b/ios/Rooms/Tests/RoomsTests/RoomServiceTests.swift @@ -26,7 +26,8 @@ struct RoomServiceTests { let sut = LiveRoomService( roomLoader: stubLoader, roomBookingLoader: roomBookingLoader, - roomRatingLoader: StubRoomRatingLoader()) + roomRatingLoader: StubRoomRatingLoader(), + roomFilterLoader: StubFilterRoomLoader()) // When let result = await sut.getRooms(buildingId: "K-J17") @@ -50,7 +51,8 @@ struct RoomServiceTests { let sut = LiveRoomService( roomLoader: stubLoader, roomBookingLoader: roomBookingLoader, - roomRatingLoader: StubRoomRatingLoader()) + roomRatingLoader: StubRoomRatingLoader(), + roomFilterLoader: StubFilterRoomLoader()) // When let result = await sut.getRooms(buildingId: "K-F21") @@ -74,7 +76,8 @@ struct RoomServiceTests { let sut = LiveRoomService( roomLoader: stubLoader, roomBookingLoader: roomBookingLoader, - roomRatingLoader: StubRoomRatingLoader()) + roomRatingLoader: StubRoomRatingLoader(), + roomFilterLoader: StubFilterRoomLoader()) // When let result = await sut.getRooms(buildingId: "K-J17") @@ -99,7 +102,8 @@ struct RoomServiceTests { let sut = LiveRoomService( roomLoader: stubLoader, roomBookingLoader: roomBookingLoader, - roomRatingLoader: StubRoomRatingLoader()) + roomRatingLoader: StubRoomRatingLoader(), + roomFilterLoader: StubFilterRoomLoader()) // When let result = await sut.getRooms(buildingId: "K-J17") @@ -124,7 +128,8 @@ struct RoomServiceTests { let sut = LiveRoomService( roomLoader: stubLoader, roomBookingLoader: roomBookingLoader, - roomRatingLoader: StubRoomRatingLoader()) + roomRatingLoader: StubRoomRatingLoader(), + roomFilterLoader: StubFilterRoomLoader()) // When let result = await sut.getRooms(buildingId: "") @@ -230,5 +235,6 @@ private func makeRoomServiceSUT(ratingLoader: StubRoomRatingLoader) -> LiveRoomS return LiveRoomService( roomLoader: stubLoader, roomBookingLoader: roomBookingLoader, - roomRatingLoader: ratingLoader) + roomRatingLoader: ratingLoader, + roomFilterLoader: StubFilterRoomLoader()) } diff --git a/ios/Rooms/Tests/RoomsTests/TestHelpers/HTTPResponseFactory.swift b/ios/Rooms/Tests/RoomsTests/TestHelpers/HTTPResponseFactory.swift new file mode 100644 index 00000000..2a24292a --- /dev/null +++ b/ios/Rooms/Tests/RoomsTests/TestHelpers/HTTPResponseFactory.swift @@ -0,0 +1,37 @@ +// +// HTTPResponseFactory.swift +// Networking +// +// Created by Yanlin Li on 26/4/2026. +// + +import Foundation + +/// A helper function to create a mock HTTP response for testing purposes +/// +/// - Parameters: +/// - route: The API route for which the response is being created (e.g., "/api/rooms/search") +/// - json: The JSON string representing the response body +/// - statusCode: The HTTP status code for the response (default is 200) +/// +/// - Returns: A tuple containing the response data and an HTTPURLResponse object +func makeHTTPResponse( + route: String, + json: String, + statusCode: Int = 200) + -> (Data, HTTPURLResponse) +{ + let url = URL(string: "https://freerooms.devsoc.app\(route)")! + + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + + return (Data(json.utf8), response) +} + +// MARK: - AnyError + +struct AnyError: Error { }