diff --git a/AutoCat.xcodeproj/project.pbxproj b/AutoCat.xcodeproj/project.pbxproj index 160fcca..230c541 100644 --- a/AutoCat.xcodeproj/project.pbxproj +++ b/AutoCat.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ 7A4927D52CCE438600851C01 /* OptionalBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4927D42CCE438600851C01 /* OptionalBinding.swift */; }; 7A530B7A24001D3300CBFE6E /* CheckController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A530B7924001D3300CBFE6E /* CheckController.swift */; }; 7A530B7E24017FEE00CBFE6E /* VehicleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A530B7D24017FEE00CBFE6E /* VehicleCell.swift */; }; + 7A54BFD32D43B95E00176D6D /* DbUpdatePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A54BFD22D43B95E00176D6D /* DbUpdatePolicy.swift */; }; 7A599C362C18AC7F00D47C18 /* ApiError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A599C352C18AC7F00D47C18 /* ApiError.swift */; }; 7A599C392C18B22900D47C18 /* FbRefreshTokenModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A599C382C18B22900D47C18 /* FbRefreshTokenModel.swift */; }; 7A599C3B2C18B36A00D47C18 /* FbVerifyTokenModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A599C3A2C18B36A00D47C18 /* FbVerifyTokenModel.swift */; }; @@ -334,6 +335,7 @@ 7A530B7924001D3300CBFE6E /* CheckController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckController.swift; sourceTree = ""; }; 7A530B7D24017FEE00CBFE6E /* VehicleCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleCell.swift; sourceTree = ""; }; 7A530B7F2401803A00CBFE6E /* Vehicle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Vehicle.swift; sourceTree = ""; }; + 7A54BFD22D43B95E00176D6D /* DbUpdatePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DbUpdatePolicy.swift; sourceTree = ""; }; 7A599C352C18AC7F00D47C18 /* ApiError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiError.swift; sourceTree = ""; }; 7A599C382C18B22900D47C18 /* FbRefreshTokenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FbRefreshTokenModel.swift; sourceTree = ""; }; 7A599C3A2C18B36A00D47C18 /* FbVerifyTokenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FbVerifyTokenModel.swift; sourceTree = ""; }; @@ -468,9 +470,21 @@ 7AFBE8CD2C308B53003C491D /* ACMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACMessageView.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7A54BFD52D43D7E100176D6D /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Extensions/TestError.swift, + "Extensions/VehicleDto+Presets.swift", + "Extensions/VehicleEventDto+Presets.swift", + ); + target = 7A2E6FA22C42B3AD00C40DA7 /* AutoCatCoreTests */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 7A2E6FA42C42B3AD00C40DA7 /* AutoCatCoreTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = AutoCatCoreTests; sourceTree = ""; }; - 7AB587232C42D27F00FA7B66 /* AutoCatTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = AutoCatTests; sourceTree = ""; }; + 7AB587232C42D27F00FA7B66 /* AutoCatTests */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7A54BFD52D43D7E100176D6D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = AutoCatTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -973,6 +987,7 @@ 7A45FB372C27073700618694 /* StorageService.swift */, 7AB587332C42D3FA00FA7B66 /* StorageService+Notes.swift */, 7AA514DF2D0B75B3001CAC50 /* StorageService+Events.swift */, + 7A54BFD22D43B95E00176D6D /* DbUpdatePolicy.swift */, ); path = StorageService; sourceTree = ""; @@ -1479,6 +1494,7 @@ 7A6B65B32CFB0DB500AABA6B /* NullifyDate.swift in Sources */, 7A7097C22C9EC139007CFDCA /* ServiceContainer.swift in Sources */, 7A7097C62C9EC77A007CFDCA /* ServicePropertyWrapper.swift in Sources */, + 7A54BFD32D43B95E00176D6D /* DbUpdatePolicy.swift in Sources */, 7A5D84BE2C1AE44700C2209B /* VehiclePhoto.swift in Sources */, 7A64A2262C1A32C800284124 /* AudioRecordDto.swift in Sources */, 7A761C09267E8EE40005F28F /* Base64FS.swift in Sources */, diff --git a/AutoCat/SceneDelegate.swift b/AutoCat/SceneDelegate.swift index 39a08ad..684f426 100644 --- a/AutoCat/SceneDelegate.swift +++ b/AutoCat/SceneDelegate.swift @@ -43,7 +43,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let settingsService = SettingsService(defaults: .standard) container.register(SettingsServiceProtocol.self, instance: settingsService) - container.register(ApiServiceProtocol.self, instance: ApiService()) + + let apiService = ApiService() + container.register(ApiServiceProtocol.self, instance: apiService) let locationService = LocationService( geocoder: CLGeocoder(), @@ -53,10 +55,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { container.register(LocationServiceProtocol.self, instance: locationService) - container.register(StorageServiceProtocol.self, - instance: try await StorageService(settingsService: settingsService)) + let storageService = try await StorageService(settingsService: settingsService) + container.register(StorageServiceProtocol.self, instance: storageService) - container.register(VehicleServiceProtocol.self, instance: VehicleService()) + let vehicleService = VehicleService(apiService: apiService, + storageService: storageService, + locationService: locationService) + container.register(VehicleServiceProtocol.self, instance: vehicleService) } func setupRootController(scene: UIScene) { diff --git a/AutoCat/Screens/EventsScreen/EventsViewModel.swift b/AutoCat/Screens/EventsScreen/EventsViewModel.swift index a12d335..040e5f9 100644 --- a/AutoCat/Screens/EventsScreen/EventsViewModel.swift +++ b/AutoCat/Screens/EventsScreen/EventsViewModel.swift @@ -135,7 +135,7 @@ class EventsViewModel: ACHudContainer { await wrapWithToast { [weak self] in guard let self else { return } let vehicle = try await apiOperation() - try await storageService.updateVehicleIfExists(dto: vehicle) + try await storageService.updateVehicle(dto: vehicle, policy: .ifExists) self.vehicle = vehicle } } diff --git a/AutoCat/Screens/HistoryScreen/HistoryViewModel.swift b/AutoCat/Screens/HistoryScreen/HistoryViewModel.swift index ce01ac4..8b5e7eb 100644 --- a/AutoCat/Screens/HistoryScreen/HistoryViewModel.swift +++ b/AutoCat/Screens/HistoryScreen/HistoryViewModel.swift @@ -110,7 +110,7 @@ final class HistoryViewModel: ACHudContainer { func checkNewNumber(_ number: String) async { await wrapWithToast { [weak self] in - try await self?.vehicleService.checkAndStore(number: number) + try await self?.vehicleService.check(number: number) } } } diff --git a/AutoCat/Screens/NotesScreen/NotesViewModel.swift b/AutoCat/Screens/NotesScreen/NotesViewModel.swift index 1e4330a..3ad0bd1 100644 --- a/AutoCat/Screens/NotesScreen/NotesViewModel.swift +++ b/AutoCat/Screens/NotesScreen/NotesViewModel.swift @@ -74,7 +74,7 @@ class NotesViewModel: ACHudContainer { await wrapWithToast { [weak self] in guard let self else { return } let vehicle = try await apiOp() - try await storageService.updateVehicleIfExists(dto: vehicle) + try await storageService.updateVehicle(dto: vehicle, policy: .ifExists) self.vehicle = vehicle } } diff --git a/AutoCat/Screens/ReportScreen/ReportViewModel.swift b/AutoCat/Screens/ReportScreen/ReportViewModel.swift index 97ee4dd..a70d4d6 100644 --- a/AutoCat/Screens/ReportScreen/ReportViewModel.swift +++ b/AutoCat/Screens/ReportScreen/ReportViewModel.swift @@ -93,7 +93,7 @@ class ReportViewModel: ACHudContainer { func checkGB() async { await wrapWithToast { self.vehicle = try await self.apiService.checkVehicleGb(by: self.vehicle.getNumber()) - try await self.storageService.updateVehicleIfExists(dto: self.vehicle) + try await self.storageService.updateVehicle(dto: self.vehicle, policy: .ifExists) } } diff --git a/AutoCatCore/Models/DTO/VehicleDto.swift b/AutoCatCore/Models/DTO/VehicleDto.swift index 072ebf8..2babf05 100644 --- a/AutoCatCore/Models/DTO/VehicleDto.swift +++ b/AutoCatCore/Models/DTO/VehicleDto.swift @@ -37,6 +37,14 @@ public struct VehicleDto: Sendable, Equatable { public var synchronized: Bool = true public init() { } + + public init(number: String) { + + self.number = number + self.addedDate = Date().timeIntervalSince1970 + self.updatedDate = self.addedDate + self.synchronized = false + } } extension VehicleDto: Identifiable { diff --git a/AutoCatCore/Services/ApiService/ApiServiceProtocol.swift b/AutoCatCore/Services/ApiService/ApiServiceProtocol.swift index 2de47bc..bf7766d 100644 --- a/AutoCatCore/Services/ApiService/ApiServiceProtocol.swift +++ b/AutoCatCore/Services/ApiService/ApiServiceProtocol.swift @@ -25,5 +25,6 @@ public protocol ApiServiceProtocol: Sendable { func getRegions() async throws -> [VehicleRegion] func getYears() async throws -> [Int] + func checkVehicle(by number: String, notes: [VehicleNoteDto], events: [VehicleEventDto], force: Bool) async throws -> VehicleDto func checkVehicleGb(by number: String) async throws -> VehicleDto } diff --git a/AutoCatCore/Services/LocationService/LocationService.swift b/AutoCatCore/Services/LocationService/LocationService.swift index 28e153e..3c84113 100644 --- a/AutoCatCore/Services/LocationService/LocationService.swift +++ b/AutoCatCore/Services/LocationService/LocationService.swift @@ -18,6 +18,8 @@ public final class LocationService { private var eventTask: Task? + private(set) public var lastEvent: VehicleEventDto? + public init(geocoder: GeocoderProtocol, locationManager: SwiftLocationProtocol, settingsService: SettingsServiceProtocol) { @@ -52,6 +54,10 @@ public final class LocationService { return VehicleEventDto(lat: coordinate.latitude, lon: coordinate.longitude, addedBy: settingsService.user.email) } + + func setLastEvent(_ event: VehicleEventDto) { + lastEvent = event + } } extension LocationService: LocationServiceProtocol { @@ -77,10 +83,30 @@ extension LocationService: LocationServiceProtocol { let task = Task { let location = try await requestLocation() eventTask = nil + lastEvent = location return location } eventTask = task return try await task.value } } + + public func getRecentLocation() async throws -> VehicleEventDto { + + var event: VehicleEventDto + + if let lastEvent, Date().timeIntervalSince1970 - lastEvent.date < 100 { + event = lastEvent + } else { + event = try await requestCurrentLocation() + } + + event.address = try? await getAddressForLocation(latitude: event.latitude, longitude: event.longitude) + return event + } + + public func resetLastEvent() { + + lastEvent = nil + } } diff --git a/AutoCatCore/Services/LocationService/LocationServiceProtocol.swift b/AutoCatCore/Services/LocationService/LocationServiceProtocol.swift index fac01c6..16f99ff 100644 --- a/AutoCatCore/Services/LocationService/LocationServiceProtocol.swift +++ b/AutoCatCore/Services/LocationService/LocationServiceProtocol.swift @@ -6,9 +6,16 @@ // Copyright © 2024 Selim Mustafaev. All rights reserved. // +import Mockable + @MainActor -public protocol LocationServiceProtocol { +@Mockable +public protocol LocationServiceProtocol: Sendable { + + var lastEvent: VehicleEventDto? { get } func getAddressForLocation(latitude: Double, longitude: Double) async throws -> String func requestCurrentLocation() async throws -> VehicleEventDto + func getRecentLocation() async throws -> VehicleEventDto + func resetLastEvent() } diff --git a/AutoCatCore/Services/StorageService/DbUpdatePolicy.swift b/AutoCatCore/Services/StorageService/DbUpdatePolicy.swift new file mode 100644 index 0000000..2e3c9de --- /dev/null +++ b/AutoCatCore/Services/StorageService/DbUpdatePolicy.swift @@ -0,0 +1,13 @@ +// +// DbUpdatePolicy.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 24.01.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +public enum DbUpdatePolicy: CaseIterable { + + case always + case ifExists +} diff --git a/AutoCatCore/Services/StorageService/StorageService.swift b/AutoCatCore/Services/StorageService/StorageService.swift index 58738b1..4c55d5a 100644 --- a/AutoCatCore/Services/StorageService/StorageService.swift +++ b/AutoCatCore/Services/StorageService/StorageService.swift @@ -43,15 +43,25 @@ public actor StorageService: StorageServiceProtocol { } } - public func updateVehicleIfExists(dto: VehicleDto) async throws { + @discardableResult + public func updateVehicle(dto: VehicleDto, policy: DbUpdatePolicy) async throws -> Bool { - guard realm.object(ofType: Vehicle.self, forPrimaryKey: dto.getNumber()) != nil else { - return + let shouldUpdate = switch policy { + case .always: + true + case .ifExists: + realm.object(ofType: Vehicle.self, forPrimaryKey: dto.getNumber()) != nil + } + + guard shouldUpdate else { + return false } try await realm.asyncWrite { realm.add(Vehicle(dto: dto), update: .all) } + + return true } public func loadVehicle(number: String) async throws -> VehicleDto { diff --git a/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift b/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift index dbc16d0..905f189 100644 --- a/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift +++ b/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift @@ -18,7 +18,8 @@ public protocol StorageServiceProtocol: Sendable { // Vehicles func loadVehicles() async -> [VehicleDto] func loadVehicle(number: String) async throws -> VehicleDto - func updateVehicleIfExists(dto: VehicleDto) async throws + @discardableResult + func updateVehicle(dto: VehicleDto, policy: DbUpdatePolicy) async throws -> Bool // Notes func addNote(text: String, to number: String) async throws -> VehicleDto diff --git a/AutoCatCore/Services/VehicleService/VehicleService.swift b/AutoCatCore/Services/VehicleService/VehicleService.swift index 1d8db9e..2622a73 100644 --- a/AutoCatCore/Services/VehicleService/VehicleService.swift +++ b/AutoCatCore/Services/VehicleService/VehicleService.swift @@ -6,19 +6,82 @@ // Copyright © 2025 Selim Mustafaev. All rights reserved. // +public struct VehicleWithErrors: Sendable { + + public var vehicle: VehicleDto + public var errors: [Error] +} + public final class VehicleService { - public init() {} + let apiService: ApiServiceProtocol + let storageService: StorageServiceProtocol + let locationService: LocationServiceProtocol + + public init(apiService: ApiServiceProtocol, + storageService: StorageServiceProtocol, + locationService: LocationServiceProtocol) { + + self.apiService = apiService + self.storageService = storageService + self.locationService = locationService + } } extension VehicleService: VehicleServiceProtocol { - public func check(number: String) async throws -> VehicleDto { + func check(number: String, + forceUpdate: Bool, + trackLocation: Bool, + dbUpdatePolicy: DbUpdatePolicy) async throws -> VehicleWithErrors { - VehicleDto() + var vehicle = (try? await storageService.loadVehicle(number: number)) ?? VehicleDto(number: number) + var errors: [Error] = [] + + let events = vehicle.events + let notes = vehicle.notes + + async let locationTask = trackLocation ? locationService.getRecentLocation() : nil + async let vehicleTask = apiService.checkVehicle(by: number, notes: notes, events: events, force: forceUpdate) + + do { + vehicle = try await vehicleTask + } catch { + errors.append(error) + } + + if trackLocation { + do { + if let event = try await locationTask { + vehicle.events.append(event) + vehicle.synchronized = false + if !vehicle.unrecognized { + vehicle = try await apiService.add(event: event, to: number) + } + } + } catch { + errors.append(error) + } + } + + await locationService.resetLastEvent() + try await storageService.updateVehicle(dto: vehicle, policy: dbUpdatePolicy) + + return VehicleWithErrors(vehicle: vehicle, errors: errors) } - public func checkAndStore(number: String) async throws { + public func check(number: String) async throws -> VehicleWithErrors { + try await check(number: number, forceUpdate: false, trackLocation: true, dbUpdatePolicy: .always) + } + + public func updateHistory(number: String) async throws -> VehicleWithErrors { + + try await check(number: number, forceUpdate: true, trackLocation: false, dbUpdatePolicy: .always) + } + + public func updateSearch(number: String) async throws -> VehicleWithErrors { + + try await check(number: number, forceUpdate: true, trackLocation: false, dbUpdatePolicy: .ifExists) } } diff --git a/AutoCatCore/Services/VehicleService/VehicleServiceProtocol.swift b/AutoCatCore/Services/VehicleService/VehicleServiceProtocol.swift index c9d2639..a48ea50 100644 --- a/AutoCatCore/Services/VehicleService/VehicleServiceProtocol.swift +++ b/AutoCatCore/Services/VehicleService/VehicleServiceProtocol.swift @@ -8,6 +8,7 @@ public protocol VehicleServiceProtocol: Sendable { - func check(number: String) async throws -> VehicleDto - func checkAndStore(number: String) async throws + func check(number: String) async throws -> VehicleWithErrors + func updateHistory(number: String) async throws -> VehicleWithErrors + func updateSearch(number: String) async throws -> VehicleWithErrors } diff --git a/AutoCatCoreTests/LocationServiceTests.swift b/AutoCatCoreTests/LocationServiceTests.swift index c125145..7360e4d 100644 --- a/AutoCatCoreTests/LocationServiceTests.swift +++ b/AutoCatCoreTests/LocationServiceTests.swift @@ -174,6 +174,7 @@ struct LocationServiceTests { #expect(event.latitude == latitude) #expect(event.longitude == longitude) + #expect(locationService.lastEvent != nil) } @Test("Get location: no location") @@ -190,6 +191,8 @@ struct LocationServiceTests { await #expect(throws: LocationError.generic) { _ = try await locationService.requestCurrentLocation() } + + #expect(locationService.lastEvent == nil) } @Test("Get location: parallel requests") @@ -222,4 +225,106 @@ struct LocationServiceTests { #expect(event3.latitude == latitude) #expect(event3.longitude == longitude) } + + + + @Test("Get recent location (request new)", arguments: [true, false]) + func getRecentLocationNew(hasAddress: Bool) async throws { + + let placemark = CLPlacemark(location: location, name: address, postalAddress: nil) + + given(locationManagerMock) + .authorizationStatus + .willReturn(.authorizedWhenInUse) + + given(locationManagerMock) + .requestLocation(accuracy: .any, timeout: .any) + .willReturn(.didUpdateLocations([location])) + + given(geocoderMock) + .reverseGeocodeLocation(.any) + .willReturn(hasAddress ? [placemark] : []) + + let event = try await locationService.getRecentLocation() + + verify(locationManagerMock) + .requestLocation(accuracy: .any, timeout: .any) + .called(.once) + + #expect(event.latitude == latitude) + #expect(event.longitude == longitude) + #expect(locationService.lastEvent != nil) + #expect(event.address == (hasAddress ? address : nil)) + } + + @Test("Get recent location (return existing)", arguments: [true, false]) + func getRecentLocationExisting(hasAddress: Bool) async throws { + + let placemark = CLPlacemark(location: location, name: address, postalAddress: nil) + + given(locationManagerMock) + .authorizationStatus + .willReturn(.authorizedWhenInUse) + + given(geocoderMock) + .reverseGeocodeLocation(.any) + .willReturn(hasAddress ? [placemark] : []) + + locationService.setLastEvent(VehicleEventDto(lat: latitude, lon: longitude, addedBy: nil)) + + let event = try await locationService.getRecentLocation() + + verify(locationManagerMock) + .requestLocation(accuracy: .any, timeout: .any) + .called(.never) + + #expect(event.latitude == latitude) + #expect(event.longitude == longitude) + #expect(locationService.lastEvent != nil) + #expect(event.address == (hasAddress ? address : nil)) + } + + @Test("Get recent location (update existing)", arguments: [true, false]) + func getRecentLocationUpdateExisting(hasAddress: Bool) async throws { + + let placemark = CLPlacemark(location: location, name: address, postalAddress: nil) + + given(locationManagerMock) + .authorizationStatus + .willReturn(.authorizedWhenInUse) + + given(locationManagerMock) + .requestLocation(accuracy: .any, timeout: .any) + .willReturn(.didUpdateLocations([location])) + + given(geocoderMock) + .reverseGeocodeLocation(.any) + .willReturn(hasAddress ? [placemark] : []) + + // Set existing date which is too old and should not be used + var existingEvent = VehicleEventDto(lat: 0, lon: 0, addedBy: nil) + existingEvent.date = try #require(Calendar.current.date(byAdding: .day, value: -1, to: Date())).timeIntervalSince1970 + locationService.setLastEvent(existingEvent) + + let event = try await locationService.getRecentLocation() + + verify(locationManagerMock) + .requestLocation(accuracy: .any, timeout: .any) + .called(.once) + + #expect(event.latitude == latitude) + #expect(event.longitude == longitude) + #expect(locationService.lastEvent != nil) + #expect(event.address == (hasAddress ? address : nil)) + } + + @Test("Reset last event") + func resetLastEvent() { + + locationService.setLastEvent(VehicleEventDto(lat: latitude, lon: longitude, addedBy: nil)) + + locationService.resetLastEvent() + + #expect(locationService.lastEvent == nil) + } } diff --git a/AutoCatCoreTests/Storage/StorageServiceTests.swift b/AutoCatCoreTests/Storage/StorageServiceTests.swift index 0217b83..d757cf1 100644 --- a/AutoCatCoreTests/Storage/StorageServiceTests.swift +++ b/AutoCatCoreTests/Storage/StorageServiceTests.swift @@ -64,4 +64,29 @@ struct StorageServiceTests { _ = try await storageService.loadVehicle(number: nonExistingVehicleNumber) } } + + @Test("Update existing vehicle", arguments: DbUpdatePolicy.allCases) + func updateVehicle(policy: DbUpdatePolicy) async throws { + + let vehicle = VehicleDto(number: existingVehicleNumber) + + let updated = try await storageService.updateVehicle(dto: vehicle, policy: policy) + + #expect(updated == true) + } + + @Test("Update non-existent vehicle", arguments: DbUpdatePolicy.allCases) + func updateNonExistentVehicle(policy: DbUpdatePolicy) async throws { + + let vehicle = VehicleDto(number: nonExistingVehicleNumber) + + let updated = try await storageService.updateVehicle(dto: vehicle, policy: policy) + + switch policy { + case .always: + #expect(updated == true) + case .ifExists: + #expect(updated == false) + } + } } diff --git a/AutoCatCoreTests/VehicleServiceTests.swift b/AutoCatCoreTests/VehicleServiceTests.swift new file mode 100644 index 0000000..d8ebdb1 --- /dev/null +++ b/AutoCatCoreTests/VehicleServiceTests.swift @@ -0,0 +1,424 @@ +// +// VehicleServiceTests.swift +// AutoCatCoreTests +// +// Created by Selim Mustafaev on 24.01.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import Testing +import Mockable +import AutoCatCore + +struct VehicleServiceTests { + + let apiServiceMock = MockApiServiceProtocol() + let storageServiceMock = MockStorageServiceProtocol() + let locationServiceMock: MockLocationServiceProtocol + + let vehicleService: VehicleServiceProtocol + + let existingVehicleNumber = "А123АА761" + let nonExistingVehicleNumber = "А999АА761" + let testVin = "1234567890" + + init () async throws { + + self.locationServiceMock = await .init() + + self.vehicleService = VehicleService(apiService: apiServiceMock, + storageService: storageServiceMock, + locationService: locationServiceMock) + } + + @Test("Check vehicle (all throws)") + func checkVehicleAllThrows() async throws { + + given(storageServiceMock) + .loadVehicle(number: .any) + .willThrow(TestError.generic) + + given(locationServiceMock) + .getRecentLocation() + .willThrow(TestError.generic) + + given(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .any) + .willThrow(TestError.generic) + + given(locationServiceMock) + .resetLastEvent() + .willReturn() + + given(storageServiceMock) + .updateVehicle(dto: .any, policy: .any) + .willThrow(TestError.generic) + + await #expect(throws: TestError.generic) { + _ = try await vehicleService.check(number: existingVehicleNumber) + } + } + + @Test("Check vehicle (all but DB update throws)") + func checkVehicleDbUpdate() async throws { + + given(storageServiceMock) + .loadVehicle(number: .any) + .willThrow(TestError.generic) + + given(locationServiceMock) + .getRecentLocation() + .willThrow(TestError.generic) + + given(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .any) + .willThrow(TestError.generic) + + given(locationServiceMock) + .resetLastEvent() + .willReturn() + + given(storageServiceMock) + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) + + let result = try await vehicleService.check(number: existingVehicleNumber) + + #expect(result.vehicle.unrecognized) + #expect(result.vehicle.events.isEmpty) + #expect(result.errors.count == 2) + } + + @Test("Check vehicle (existing unrecognized vehicle)") + func checkVehicleExistingVehicle() async throws { + + var vehicle = VehicleDto(number: existingVehicleNumber) + vehicle.vin1 = testVin + + given(storageServiceMock) + .loadVehicle(number: .any) + .willReturn(vehicle) + + given(locationServiceMock) + .getRecentLocation() + .willThrow(TestError.generic) + + given(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .any) + .willThrow(TestError.generic) + + given(locationServiceMock) + .resetLastEvent() + .willReturn() + + given(storageServiceMock) + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) + + let result = try await vehicleService.check(number: existingVehicleNumber) + + #expect(result.vehicle.vin1 == testVin) + #expect(result.errors.count == 2) + } + + @Test("Check vehicle (location received)") + func checkVehicleLocationReceived() async throws { + + var vehicle = VehicleDto(number: existingVehicleNumber) + vehicle.vin1 = testVin + + given(storageServiceMock) + .loadVehicle(number: .any) + .willReturn(vehicle) + + given(locationServiceMock) + .getRecentLocation() + .willReturn(.valid) + + given(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .any) + .willThrow(TestError.generic) + + given(locationServiceMock) + .resetLastEvent() + .willReturn() + + given(storageServiceMock) + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) + + let result = try await vehicleService.check(number: VehicleDto.validNumber) + + #expect(result.vehicle.vin1 == testVin) + #expect(result.errors.count == 1) + #expect(result.vehicle.events.count == 1) + #expect(result.vehicle.events.first?.latitude == VehicleEventDto.validLatitude) + #expect(result.vehicle.events.first?.longitude == VehicleEventDto.validLongitude) + } + + @Test("Check vehicle (existing normal vehicle)") + func checkVehicleExistingNormalVehicle() async throws { + + given(storageServiceMock) + .loadVehicle(number: .any) + .willReturn(.normal) + + given(locationServiceMock) + .getRecentLocation() + .willReturn(.valid) + + given(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .any) + .willThrow(TestError.generic) + + given(locationServiceMock) + .resetLastEvent() + .willReturn() + + given(storageServiceMock) + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) + + given(apiServiceMock) + .add(event: .any, to: .any) + .willThrow(TestError.generic) + + let result = try await vehicleService.check(number: VehicleDto.validNumber) + + #expect(result.vehicle.number == VehicleDto.validNumber) + #expect(result.errors.count == 2) + #expect(result.vehicle.events.count == 1) + #expect(result.vehicle.events.first?.latitude == VehicleEventDto.validLatitude) + #expect(result.vehicle.events.first?.longitude == VehicleEventDto.validLongitude) + } + + @Test("Check vehicle (existing normal vehicle, add event)") + func checkVehicleExistingNormalVehicleAddEvent() async throws { + + given(storageServiceMock) + .loadVehicle(number: .any) + .willReturn(.normal) + + given(locationServiceMock) + .getRecentLocation() + .willReturn(.valid) + + given(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .any) + .willThrow(TestError.generic) + + given(locationServiceMock) + .resetLastEvent() + .willReturn() + + given(storageServiceMock) + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) + + given(apiServiceMock) + .add(event: .any, to: .any) + .willReturn(.normal.addEvent(.valid)) + + let result = try await vehicleService.check(number: VehicleDto.validNumber) + + #expect(result.vehicle.number == VehicleDto.validNumber) + #expect(result.errors.count == 1) + #expect(result.vehicle.events.count == 1) + #expect(result.vehicle.events.first?.latitude == VehicleEventDto.validLatitude) + #expect(result.vehicle.events.first?.longitude == VehicleEventDto.validLongitude) + } + + @Test("Check vehicle (with server check working)") + func checkVehicleServerCheck() async throws { + + given(storageServiceMock) + .loadVehicle(number: .any) + .willReturn(.normal) + + given(locationServiceMock) + .getRecentLocation() + .willReturn(.valid) + + given(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .any) + .willReturn(.normal) + + given(locationServiceMock) + .resetLastEvent() + .willReturn() + + given(storageServiceMock) + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) + + given(apiServiceMock) + .add(event: .any, to: .any) + .willReturn(.normal.addEvent(.valid)) + + let result = try await vehicleService.check(number: VehicleDto.validNumber) + + #expect(result.vehicle.number == VehicleDto.validNumber) + #expect(result.errors.count == 0) + #expect(result.vehicle.events.count == 1) + #expect(result.vehicle.events.first?.latitude == VehicleEventDto.validLatitude) + #expect(result.vehicle.events.first?.longitude == VehicleEventDto.validLongitude) + } + + @Test("Check") + func check() async throws { + + let vehicle: VehicleDto = .normal + + given(storageServiceMock) + .loadVehicle(number: .any) + .willReturn(.normal) + + given(locationServiceMock) + .getRecentLocation() + .willReturn(.valid) + + given(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .any) + .willReturn(vehicle) + + given(locationServiceMock) + .resetLastEvent() + .willReturn() + + given(storageServiceMock) + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) + + given(apiServiceMock) + .add(event: .any, to: .any) + .willReturn(vehicle.addEvent(.valid)) + + let result = try await vehicleService.check(number: vehicle.number) + + verify(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .value(false)) + .called(.once) + + verify(locationServiceMock) + .getRecentLocation() + .called(.once) + + verify(apiServiceMock) + .add(event: .any, to: .any) + .called(.once) + + verify(storageServiceMock) + .updateVehicle(dto: .any, policy: .value(.always)) + .called(.once) + + #expect(result.vehicle.number == vehicle.number) + #expect(result.errors.count == 0) + #expect(result.vehicle.events.count == 1) + #expect(result.vehicle.events.first?.latitude == VehicleEventDto.validLatitude) + #expect(result.vehicle.events.first?.longitude == VehicleEventDto.validLongitude) + } + + @Test("Update (history)") + func updateHistory() async throws { + + let vehicle: VehicleDto = .normal + + given(storageServiceMock) + .loadVehicle(number: .any) + .willReturn(.normal) + + given(locationServiceMock) + .getRecentLocation() + .willReturn(.valid) + + given(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .any) + .willReturn(vehicle) + + given(locationServiceMock) + .resetLastEvent() + .willReturn() + + given(storageServiceMock) + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) + + given(apiServiceMock) + .add(event: .any, to: .any) + .willReturn(vehicle.addEvent(.valid)) + + let result = try await vehicleService.updateHistory(number: vehicle.number) + + verify(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .value(true)) + .called(.once) + + verify(locationServiceMock) + .getRecentLocation() + .called(.never) + + verify(apiServiceMock) + .add(event: .any, to: .any) + .called(.never) + + verify(storageServiceMock) + .updateVehicle(dto: .any, policy: .value(.always)) + .called(.once) + + #expect(result.vehicle.number == vehicle.number) + #expect(result.errors.count == 0) + #expect(result.vehicle.events.count == 0) + } + + @Test("Update (search)") + func updateSearch() async throws { + + let vehicle: VehicleDto = .normal + + given(storageServiceMock) + .loadVehicle(number: .any) + .willReturn(.normal) + + given(locationServiceMock) + .getRecentLocation() + .willReturn(.valid) + + given(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .any) + .willReturn(vehicle) + + given(locationServiceMock) + .resetLastEvent() + .willReturn() + + given(storageServiceMock) + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) + + given(apiServiceMock) + .add(event: .any, to: .any) + .willReturn(vehicle.addEvent(.valid)) + + let result = try await vehicleService.updateSearch(number: vehicle.number) + + verify(apiServiceMock) + .checkVehicle(by: .any, notes: .any, events: .any, force: .value(true)) + .called(.once) + + verify(locationServiceMock) + .getRecentLocation() + .called(.never) + + verify(apiServiceMock) + .add(event: .any, to: .any) + .called(.never) + + verify(storageServiceMock) + .updateVehicle(dto: .any, policy: .value(.ifExists)) + .called(.once) + + #expect(result.vehicle.number == vehicle.number) + #expect(result.errors.count == 0) + #expect(result.vehicle.events.count == 0) + } +} diff --git a/AutoCatTests/EventsTests.swift b/AutoCatTests/EventsTests.swift index 14acbaa..55a5eba 100644 --- a/AutoCatTests/EventsTests.swift +++ b/AutoCatTests/EventsTests.swift @@ -21,7 +21,7 @@ struct EventsTests { var viewModel: EventsViewModel lazy var vehicleWithEvent: VehicleDto = .normal.addEvent(.valid) - lazy var unrecognizedVehicleWithEvent: VehicleDto = .unrecognized.addEvent(.valid) + lazy var unrecognizedVehicleWithEvent: VehicleDto = .unrecognizedVehicle.addEvent(.valid) init() { @@ -56,14 +56,14 @@ struct EventsTests { .willReturn(vehicleWithEvent) given(storageServiceMock) - .updateVehicleIfExists(dto: .value(updatedVehicle)) - .willReturn() + .updateVehicle(dto: .value(updatedVehicle), policy: .value(.ifExists)) + .willReturn(true) given(storageServiceMock) .add(event: .value(.valid), to: .value(VehicleDto.validNumber)) .willReturn(unrecognizedVehicleWithEvent) - viewModel = makeViewModel(vehicle: isUnrecognized ? .unrecognized : .normal) + viewModel = makeViewModel(vehicle: isUnrecognized ? .unrecognizedVehicle : .normal) await viewModel.addEvent(.valid) verify(apiServiceMock) @@ -71,7 +71,7 @@ struct EventsTests { .called(isUnrecognized ? .never : .once) verify(storageServiceMock) - .updateVehicleIfExists(dto: .any) + .updateVehicle(dto: .any, policy: .any) .called(isUnrecognized ? .never : .once) verify(storageServiceMock) @@ -89,19 +89,19 @@ struct EventsTests { mutating func deleteEvent(isUnrecognized: Bool) async throws { let vehicleWithEvent: VehicleDto = isUnrecognized ? unrecognizedVehicleWithEvent : vehicleWithEvent - let vehicle: VehicleDto = isUnrecognized ? .unrecognized : .normal + let vehicle: VehicleDto = isUnrecognized ? .unrecognizedVehicle : .normal given(apiServiceMock) .remove(event: .value(VehicleEventDto.validId)) .willReturn(.normal) given(storageServiceMock) - .updateVehicleIfExists(dto: .value(vehicle)) - .willReturn() + .updateVehicle(dto: .value(vehicle), policy: .value(.ifExists)) + .willReturn(true) given(storageServiceMock) .remove(event: .value(VehicleEventDto.validId), from: .value(VehicleDto.validNumber)) - .willReturn(.unrecognized) + .willReturn(.unrecognizedVehicle) viewModel = makeViewModel(vehicle: vehicleWithEvent) await viewModel.deleteEvent(VehicleEventDto.valid.viewModel) @@ -111,7 +111,7 @@ struct EventsTests { .called(isUnrecognized ? .never : .once) verify(storageServiceMock) - .updateVehicleIfExists(dto: .any) + .updateVehicle(dto: .any, policy: .any) .called(isUnrecognized ? .never : .once) verify(storageServiceMock) diff --git a/AutoCatTests/Extensions/TestError.swift b/AutoCatTests/Extensions/TestError.swift index d186183..2018ec0 100644 --- a/AutoCatTests/Extensions/TestError.swift +++ b/AutoCatTests/Extensions/TestError.swift @@ -18,3 +18,10 @@ enum TestError: LocalizedError { } } } + +extension Error { + + static var testError: Error { + return TestError.generic + } +} diff --git a/AutoCatTests/Extensions/VehicleDto+Presets.swift b/AutoCatTests/Extensions/VehicleDto+Presets.swift index bd7ce9a..b73b30c 100644 --- a/AutoCatTests/Extensions/VehicleDto+Presets.swift +++ b/AutoCatTests/Extensions/VehicleDto+Presets.swift @@ -11,6 +11,7 @@ import AutoCatCore extension VehicleDto { static let validNumber: String = "А123АА761" + static let validNumber2: String = "А456АА761" static var normal: VehicleDto { var vehicle = VehicleDto() @@ -19,7 +20,14 @@ extension VehicleDto { return vehicle } - static var unrecognized: VehicleDto { + static var normal2: VehicleDto { + var vehicle = VehicleDto() + vehicle.number = validNumber2 + vehicle.brand = VehicleBrandDto() + return vehicle + } + + static var unrecognizedVehicle: VehicleDto { var vehicle = VehicleDto() vehicle.number = validNumber return vehicle diff --git a/AutoCatTests/Extensions/VehicleEventDto+Presets.swift b/AutoCatTests/Extensions/VehicleEventDto+Presets.swift index b7803c3..d5df999 100644 --- a/AutoCatTests/Extensions/VehicleEventDto+Presets.swift +++ b/AutoCatTests/Extensions/VehicleEventDto+Presets.swift @@ -8,7 +8,6 @@ import CoreLocation import AutoCatCore -@testable import AutoCat extension VehicleEventDto { @@ -34,15 +33,4 @@ extension VehicleEventDto { event.address = testAddress return event } - - var viewModel: EventModel { - - EventModel( - id: id, - date: "", - coordinate: CLLocationCoordinate2DMake(latitude, longitude), - address: address ?? "", - isMe: true - ) - } } diff --git a/AutoCatTests/Extensions/VehicleEventDto+ViewModel.swift b/AutoCatTests/Extensions/VehicleEventDto+ViewModel.swift new file mode 100644 index 0000000..99d85a9 --- /dev/null +++ b/AutoCatTests/Extensions/VehicleEventDto+ViewModel.swift @@ -0,0 +1,25 @@ +// +// VehicleEventDto+ViewModel.swift +// AutoCatTests +// +// Created by Selim Mustafaev on 25.01.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +@testable import AutoCat +import AutoCatCore +import CoreLocation + +extension VehicleEventDto { + + var viewModel: EventModel { + + EventModel( + id: id, + date: "", + coordinate: CLLocationCoordinate2DMake(latitude, longitude), + address: address ?? "", + isMe: true + ) + } +} diff --git a/AutoCatTests/NotesTests.swift b/AutoCatTests/NotesTests.swift index b0a5669..893d62b 100644 --- a/AutoCatTests/NotesTests.swift +++ b/AutoCatTests/NotesTests.swift @@ -23,7 +23,7 @@ final class NotesTests { let noteTextModified = "Test note text modified" lazy var vehicleWithNote: VehicleDto = .normal.addNote(text: noteText) - lazy var unrecognizedVehicleWithNote: VehicleDto = .unrecognized.addNote(text: noteText) + lazy var unrecognizedVehicleWithNote: VehicleDto = .unrecognizedVehicle.addNote(text: noteText) init() { storageServiceMock = MockStorageServiceProtocol() @@ -44,8 +44,8 @@ final class NotesTests { .willReturn(vehicleWithNote) given(storageServiceMock) - .updateVehicleIfExists(dto: .any) - .willReturn() + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) viewModel.vehicle = .normal @@ -56,7 +56,7 @@ final class NotesTests { verify(storageServiceMock) .addNote(text: .any, to: .any).called(.never) - .updateVehicleIfExists(dto: .any).called(.once) + .updateVehicle(dto: .any, policy: .any).called(.once) #expect(viewModel.vehicle.notes.contains { $0.text == noteText }) #expect(viewModel.hud == nil) @@ -69,7 +69,7 @@ final class NotesTests { .addNote(text: .any, to: .any) .willReturn(vehicleWithNote) - viewModel.vehicle = .unrecognized + viewModel.vehicle = .unrecognizedVehicle await viewModel.addNote(text: noteText) @@ -78,7 +78,7 @@ final class NotesTests { verify(storageServiceMock) .addNote(text: .any, to: .any).called(.once) - .updateVehicleIfExists(dto: .any).called(.never) + .updateVehicle(dto: .any, policy: .any).called(.never) #expect(viewModel.vehicle.notes.contains { $0.text == noteText }) #expect(viewModel.hud == nil) @@ -95,8 +95,8 @@ final class NotesTests { .willReturn(modifiedVehicle) given(storageServiceMock) - .updateVehicleIfExists(dto: .any) - .willReturn() + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) viewModel.vehicle = vehicleWithNote @@ -107,7 +107,7 @@ final class NotesTests { verify(storageServiceMock) .editNote(id: .any, text: .any, for: .any).called(.never) - .updateVehicleIfExists(dto: .any).called(.once) + .updateVehicle(dto: .any, policy: .any).called(.once) #expect(viewModel.vehicle.notes.contains { $0.text == noteTextModified }) #expect(viewModel.hud == nil) @@ -116,12 +116,12 @@ final class NotesTests { @Test("Edit note (unrecognized vehicle)") func editNoteUnrecognized() async throws { - let vehicle: VehicleDto = .unrecognized.addNote(text: noteText) + let vehicle: VehicleDto = .unrecognizedVehicle.addNote(text: noteText) let noteId = try #require(vehicle.notes.first?.id) given(storageServiceMock) .editNote(id: .value(noteId), text: .value(noteTextModified), for: .any) - .willReturn(.unrecognized.addNote(text: noteTextModified, id: noteId)) + .willReturn(.unrecognizedVehicle.addNote(text: noteTextModified, id: noteId)) viewModel.vehicle = vehicle @@ -132,7 +132,7 @@ final class NotesTests { verify(storageServiceMock) .editNote(id: .any, text: .any, for: .any).called(.once) - .updateVehicleIfExists(dto: .any).called(.never) + .updateVehicle(dto: .any, policy: .any).called(.never) #expect(viewModel.vehicle.notes.contains { $0.text == noteTextModified }) #expect(viewModel.hud == nil) @@ -148,8 +148,8 @@ final class NotesTests { .willReturn(.normal) given(storageServiceMock) - .updateVehicleIfExists(dto: .any) - .willReturn() + .updateVehicle(dto: .any, policy: .any) + .willReturn(true) viewModel.vehicle = vehicleWithNote @@ -160,7 +160,7 @@ final class NotesTests { verify(storageServiceMock) .deleteNote(id: .any, for: .any).called(.never) - .updateVehicleIfExists(dto: .any).called(.once) + .updateVehicle(dto: .any, policy: .any).called(.once) #expect(!viewModel.vehicle.notes.contains { $0.text == noteText }) #expect(viewModel.hud == nil) @@ -173,7 +173,7 @@ final class NotesTests { given(storageServiceMock) .deleteNote(id: .value(noteId), for: .any) - .willReturn(.unrecognized) + .willReturn(.unrecognizedVehicle) viewModel.vehicle = unrecognizedVehicleWithNote @@ -184,7 +184,7 @@ final class NotesTests { verify(storageServiceMock) .deleteNote(id: .value(noteId), for: .any).called(.once) - .updateVehicleIfExists(dto: .any).called(.never) + .updateVehicle(dto: .any, policy: .any).called(.never) #expect(!viewModel.vehicle.notes.contains { $0.text == noteText }) #expect(viewModel.hud == nil) diff --git a/AutoCatTests/ReportTests.swift b/AutoCatTests/ReportTests.swift index cc99036..5b7463e 100644 --- a/AutoCatTests/ReportTests.swift +++ b/AutoCatTests/ReportTests.swift @@ -110,8 +110,8 @@ class ReportTests { .willReturn(updatedVehicle) given(storageServiceMock) - .updateVehicleIfExists(dto: .value(updatedVehicle)) - .willReturn() + .updateVehicle(dto: .value(updatedVehicle), policy: .value(.ifExists)) + .willReturn(true) given(storageServiceMock) .loadVehicle(number: .value(existingVehicleNumber)) @@ -125,7 +125,7 @@ class ReportTests { .called(.once) verify(storageServiceMock) - .updateVehicleIfExists(dto: .value(updatedVehicle)) + .updateVehicle(dto: .value(updatedVehicle), policy: .value(.ifExists)) .called(.once) verify(storageServiceMock)