import Foundation /// In-memory server substitute. Bookmarks are isolated by sales code. /// All error scenarios can be injected without special user-facing search strings. @MainActor final class StubLeadRemoteService: LeadRemoteService { enum Scenario { case success, offline, timeout, http(Int), malformedResponse } var scenario: Scenario var delay: Duration var listDelay: Duration? private var records: [LeadDTO] private var bookmarksByUser: [String: Set] = [:] init(scenario: Scenario = .success, delay: Duration = .milliseconds(350), records: [LeadDTO] = LeadSampleData.records, listDelay: Duration? = nil) { self.scenario = scenario self.delay = delay self.listDelay = listDelay self.records = records } func fetchLeads(salesCode: String, query: LeadQuery) async throws -> [LeadDTO] { try await simulate(delay: listDelay) let terms = query.normalized.searchText.split(whereSeparator: \.isWhitespace).map(String.init) var result = records.map { personalized($0, salesCode: salesCode) }.filter { lead in let fields = [lead.id, lead.companyName, lead.contactTitle, lead.firstName, lead.lastName, lead.street, lead.postalCode, lead.city] .compactMap { $0 }.joined(separator: " ") return terms.allSatisfy { fields.localizedStandardContains($0) } && (query.filter == .all || lead.unsignedContractCount > 0) } result.sort { switch query.sort { case .newest: return $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt case .nameAscending: let comparison = name($0).localizedStandardCompare(name($1)) return comparison == .orderedSame ? $0.id < $1.id : comparison == .orderedAscending } } return result } func fetchLead(id: String, salesCode: String) async throws -> LeadDTO { try await simulate() guard let record = records.first(where: { $0.id == id }) else { throw LeadRemoteError.http(statusCode: 404) } return personalized(record, salesCode: salesCode) } func setBookmark(_ bookmarked: Bool, id: String, salesCode: String) async throws -> LeadDTO { try await simulate() guard let record = records.first(where: { $0.id == id }) else { throw LeadRemoteError.http(statusCode: 404) } var bookmarks = bookmarks(for: salesCode) if bookmarked { bookmarks.insert(id) } else { bookmarks.remove(id) } bookmarksByUser[salesCode] = bookmarks return personalized(record, salesCode: salesCode) } private func simulate(delay override: Duration? = nil) async throws { try await Task.sleep(for: override ?? delay) try Task.checkCancellation() switch scenario { case .success: break case .offline: throw URLError(.notConnectedToInternet) case .timeout: throw URLError(.timedOut) case .http(let status): throw LeadRemoteError.http(statusCode: status) case .malformedResponse: throw LeadRemoteError.malformedResponse } } private func bookmarks(for salesCode: String) -> Set { bookmarksByUser[salesCode] ?? Set(records.filter(\.isBookmarked).map(\.id)) } private func personalized(_ record: LeadDTO, salesCode: String) -> LeadDTO { var value = record value.isBookmarked = bookmarks(for: salesCode).contains(record.id) return value } private func name(_ record: LeadDTO) -> String { record.companyName ?? [record.contactTitle, record.firstName, record.lastName] .compactMap { $0 }.joined(separator: " ") } } enum LeadSampleData { static let records: [LeadDTO] = [ make("L-240330", first: "Thomas", last: "Brenner", street: "Am Schlossberg 9", postal: "79111", city: "Freiburg", phone: "0160 448 2210", offers: 1, contracts: 2, day: 30), make("L-240326", company: "Schreinerei Vogt", street: "Talweg 22", postal: "79252", city: "Stegen", phone: "07661 37 12", day: 26), make("L-240322", company: "Hotel Schwarzwaldblick", street: "Höhenweg 1", postal: "79874", city: "Breitnau", phone: "07652 91 80", offers: 2, contracts: 6, day: 22), make("L-240318", company: "Bäckerei Hoffmann GmbH", street: "Marktstraße 14", postal: "79098", city: "Freiburg", phone: "0761 204 118", offers: 2, contracts: 5, day: 18, bookmarked: true), make("L-240309", company: "Kita Sonnenland e.V.", street: "Schulstraße 5", postal: "79224", city: "Umkirch", phone: "07665 50 02 1", offers: 1, contracts: 1, day: 9), make("L-240298", company: "Familie Yilmaz", street: "Rosenweg 17", postal: "79194", city: "Gundelfingen", phone: "0152 019 33 87", day: 8), make("L-240291", title: "Dr.", first: "Sabine", last: "Wendt", street: "Gartenstraße 8", postal: "79100", city: "Freiburg", phone: "0171 552 0193", offers: 1, contracts: 3, day: 7, bookmarked: true), make("L-240277", company: "Autohaus Kessler KG", street: "Gewerbestraße 3", postal: "79199", city: "Kirchzarten", phone: "07661 90 33 0", offers: 3, contracts: 8, day: 6, bookmarked: true), make("L-240270", company: "Gärtnerei Weber", street: "Feldweg 4", postal: "79206", city: "Breisach", phone: nil, day: 5), make("L-240261", first: "Anna", last: "Schneider", street: "Bergstraße 6", postal: "79249", city: "Merzhausen", phone: "0761 555 012", offers: 1, day: 4) ] private static func make( _ id: String, company: String? = nil, title: String? = nil, first: String? = nil, last: String? = nil, street: String, postal: String, city: String, phone: String?, offers: Int = 0, contracts: Int = 0, day: Int, bookmarked: Bool = false ) -> LeadDTO { LeadDTO(id: id, companyName: company, contactTitle: title, firstName: first, lastName: last, street: street, postalCode: postal, city: city, phoneNumber: phone, createdAt: Date(timeIntervalSince1970: 1_710_000_000 + Double(day) * 86_400), offerCount: offers, unsignedContractCount: contracts, isBookmarked: bookmarked) } }