From bc0eed84c91235b3f1d4754f452d492913500be5 Mon Sep 17 00:00:00 2001 From: drunkonjava Date: Wed, 23 Jul 2025 05:03:35 -0400 Subject: [PATCH 1/2] fix: Implement actual provider protocols in SearchService - Add SearchProviderProtocol to define provider contract - Implement DefaultSearchProvider using repository interfaces - Update SearchService to use optional provider - Add SearchServiceFactory for dependency injection - Maintain backward compatibility with UserDefaults - Add comprehensive unit tests with mock repositories - Add documentation for provider integration Resolves #195 --- .claude/settings.local.json | 3 +- Services-Search/Package.swift | 3 + .../SearchProviderIntegration.md | 74 ++++++ .../Protocols/SearchProviderProtocol.swift | 88 +++++++ .../ServicesSearch/SearchService.swift | 65 ++++- .../ServicesSearch/SearchServiceFactory.swift | 35 +++ .../SearchProviderTests.swift | 243 ++++++++++++++++++ 7 files changed, 499 insertions(+), 12 deletions(-) create mode 100644 Services-Search/Sources/ServicesSearch/Documentation/SearchProviderIntegration.md create mode 100644 Services-Search/Sources/ServicesSearch/Protocols/SearchProviderProtocol.swift create mode 100644 Services-Search/Sources/ServicesSearch/SearchServiceFactory.swift create mode 100644 Services-Search/Tests/ServicesSearchTests/SearchProviderTests.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c347466c..bae1ee4c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -206,7 +206,8 @@ "Bash(time make:*)", "Bash(# Fix UIComponents imports to UI-Components\nfind . -name \"\"*.swift\"\" -type f | while read -r file; do\n if grep -q \"\"import UIComponents\"\" \"\"$file\"\"; then\n echo \"\"Fixing: $file\"\"\n sed -i '''' ''s/import UIComponents/import UI_Components/g'' \"\"$file\"\"\n fi\ndone)", "mcp__filesystem__search_files", - "mcp__filesystem__edit_file" + "mcp__filesystem__edit_file", + "mcp__github__get_issue" ], "deny": [] }, diff --git a/Services-Search/Package.swift b/Services-Search/Package.swift index cda0477b..0f5f80ab 100644 --- a/Services-Search/Package.swift +++ b/Services-Search/Package.swift @@ -26,6 +26,9 @@ let package = Package( .product(name: "FoundationModels", package: "Foundation-Models"), .product(name: "InfrastructureStorage", package: "Infrastructure-Storage"), .product(name: "InfrastructureMonitoring", package: "Infrastructure-Monitoring") + ], + resources: [ + .process("Documentation") ]), ] ) \ No newline at end of file diff --git a/Services-Search/Sources/ServicesSearch/Documentation/SearchProviderIntegration.md b/Services-Search/Sources/ServicesSearch/Documentation/SearchProviderIntegration.md new file mode 100644 index 00000000..c8496ae2 --- /dev/null +++ b/Services-Search/Sources/ServicesSearch/Documentation/SearchProviderIntegration.md @@ -0,0 +1,74 @@ +# Search Provider Integration + +## Overview + +The SearchService now supports infrastructure-based search providers through the `SearchProviderProtocol`. This allows for proper separation of concerns and integration with the storage layer. + +## Architecture + +### Provider Protocol + +The `SearchProviderProtocol` defines the contract for search infrastructure providers: + +```swift +@MainActor +public protocol SearchProviderProtocol: Sendable { + func fetchSearchHistory() async throws -> [String] + func saveSearchQuery(_ query: String, resultCount: Int) async throws + func clearSearchHistory() async throws + func getSuggestions(for partialQuery: String) async throws -> [String] + func fetchSavedSearches() async throws -> [SavedSearch] + func saveSearch(_ search: SavedSearch) async throws + func getItemSuggestions(for query: String, limit: Int) async throws -> [SearchSuggestion] +} +``` + +### Default Implementation + +The `DefaultSearchProvider` implements this protocol using repository interfaces: +- `SearchHistoryRepository` - For managing search history +- `SavedSearchRepository` - For managing saved searches +- `ItemRepository` - For fetching item suggestions + +## Usage + +### With Infrastructure + +```swift +// Create with dependencies +let searchService = SearchServiceFactory.create( + searchHistoryRepository: searchHistoryRepo, + savedSearchRepository: savedSearchRepo, + itemRepository: itemRepo +) +``` + +### Without Infrastructure (Legacy) + +```swift +// Create with UserDefaults fallback +let searchService = SearchServiceFactory.createDefault() +``` + +## Migration Path + +1. The SearchService maintains backward compatibility with UserDefaults +2. When a provider is available, it uses the infrastructure repositories +3. When no provider is specified, it falls back to UserDefaults storage +4. This allows for gradual migration without breaking existing functionality + +## Benefits + +1. **Separation of Concerns**: Search logic is separated from storage implementation +2. **Testability**: Provider protocol allows for easy mocking in tests +3. **Flexibility**: Different storage backends can be used without changing SearchService +4. **Type Safety**: Strong typing through protocol definitions +5. **Async/Await**: Modern concurrency patterns throughout + +## Testing + +The implementation includes comprehensive unit tests with mock repositories to verify: +- Search history management +- Search suggestions +- Item suggestions +- Provider integration \ No newline at end of file diff --git a/Services-Search/Sources/ServicesSearch/Protocols/SearchProviderProtocol.swift b/Services-Search/Sources/ServicesSearch/Protocols/SearchProviderProtocol.swift new file mode 100644 index 00000000..a9a98be7 --- /dev/null +++ b/Services-Search/Sources/ServicesSearch/Protocols/SearchProviderProtocol.swift @@ -0,0 +1,88 @@ +import Foundation +import FoundationCore +import FoundationModels +import InfrastructureStorage + +/// Protocol defining search provider capabilities +@MainActor +public protocol SearchProviderProtocol: Sendable { + /// Fetch search history from storage + func fetchSearchHistory() async throws -> [String] + + /// Save search query to history + func saveSearchQuery(_ query: String, resultCount: Int) async throws + + /// Clear all search history + func clearSearchHistory() async throws + + /// Get search suggestions based on partial query + func getSuggestions(for partialQuery: String) async throws -> [String] + + /// Fetch saved searches + func fetchSavedSearches() async throws -> [SavedSearch] + + /// Save a search for later use + func saveSearch(_ search: SavedSearch) async throws + + /// Get item suggestions based on query + func getItemSuggestions(for query: String, limit: Int) async throws -> [SearchSuggestion] +} + +/// Concrete implementation of search provider +@MainActor +public final class DefaultSearchProvider: SearchProviderProtocol { + private let searchHistoryRepository: SearchHistoryRepository + private let savedSearchRepository: SavedSearchRepository + private let itemRepository: ItemRepository + + public init( + searchHistoryRepository: SearchHistoryRepository, + savedSearchRepository: SavedSearchRepository, + itemRepository: ItemRepository + ) { + self.searchHistoryRepository = searchHistoryRepository + self.savedSearchRepository = savedSearchRepository + self.itemRepository = itemRepository + } + + public func fetchSearchHistory() async throws -> [String] { + let history = try await searchHistoryRepository.fetchRecent(limit: 50) + return history.map { $0.query } + } + + public func saveSearchQuery(_ query: String, resultCount: Int) async throws { + try await searchHistoryRepository.saveSearch(query, resultCount: resultCount, timestamp: Date()) + } + + public func clearSearchHistory() async throws { + try await searchHistoryRepository.deleteAll() + } + + public func getSuggestions(for partialQuery: String) async throws -> [String] { + try await searchHistoryRepository.getSuggestions(for: partialQuery, limit: 10) + } + + public func fetchSavedSearches() async throws -> [SavedSearch] { + try await savedSearchRepository.fetchAll() + } + + public func saveSearch(_ search: SavedSearch) async throws { + try await savedSearchRepository.save(search) + } + + public func getItemSuggestions(for query: String, limit: Int) async throws -> [SearchSuggestion] { + // Search for items matching the query + let items = try await itemRepository.search(query: query) + + // Convert to search suggestions + let suggestions = items.prefix(limit).map { item in + SearchSuggestion( + text: item.name, + type: .item, + score: 0.8 // Base score for item suggestions + ) + } + + return Array(suggestions) + } +} \ No newline at end of file diff --git a/Services-Search/Sources/ServicesSearch/SearchService.swift b/Services-Search/Sources/ServicesSearch/SearchService.swift index de5417bc..81a173bb 100644 --- a/Services-Search/Sources/ServicesSearch/SearchService.swift +++ b/Services-Search/Sources/ServicesSearch/SearchService.swift @@ -28,15 +28,22 @@ public final class SearchService: ObservableObject { private let maxHistoryItems = 50 private let maxSuggestions = 10 private var searchIndex: SearchIndex - - // TODO: Replace with actual provider protocols when infrastructure is ready - private static let searchHistoryKey = "search.history" + private let searchProvider: SearchProviderProtocol? // MARK: - Initialization - public init() { + public init(searchProvider: SearchProviderProtocol? = nil) { self.searchIndex = SearchIndex() - loadSearchHistory() + self.searchProvider = searchProvider + + // Load search history if provider is available + if searchProvider != nil { + Task { + await loadSearchHistoryFromProvider() + } + } else { + loadSearchHistory() + } } // MARK: - Public Methods @@ -173,7 +180,15 @@ public final class SearchService: ObservableObject { /// Clear search history public func clearHistory() { searchHistory = [] - UserDefaults.standard.removeObject(forKey: Self.searchHistoryKey) + + if let provider = searchProvider { + Task { + try? await provider.clearSearchHistory() + } + } else { + // Legacy UserDefaults implementation + UserDefaults.standard.removeObject(forKey: "search.history") + } } /// Index items for faster searching @@ -189,11 +204,23 @@ public final class SearchService: ObservableObject { // MARK: - Private Methods private func loadSearchHistory() { - if let history = UserDefaults.standard.array(forKey: Self.searchHistoryKey) as? [String] { + // Legacy UserDefaults implementation for backward compatibility + if let history = UserDefaults.standard.array(forKey: "search.history") as? [String] { searchHistory = history } } + private func loadSearchHistoryFromProvider() async { + guard let provider = searchProvider else { return } + + do { + searchHistory = try await provider.fetchSearchHistory() + } catch { + // Fall back to UserDefaults on error + loadSearchHistory() + } + } + private func addToSearchHistory(_ query: String) { // Remove if exists searchHistory.removeAll { $0 == query } @@ -206,8 +233,15 @@ public final class SearchService: ObservableObject { searchHistory = Array(searchHistory.prefix(maxHistoryItems)) } - // Save to UserDefaults - UserDefaults.standard.set(searchHistory, forKey: Self.searchHistoryKey) + // Save using provider if available, otherwise use UserDefaults + if let provider = searchProvider { + Task { + try? await provider.saveSearchQuery(query, resultCount: searchResults.count) + } + } else { + // Legacy UserDefaults implementation + UserDefaults.standard.set(searchHistory, forKey: "search.history") + } } private func performTextSearch(_ query: String, options: SearchOptions) async -> [SearchResult] { @@ -231,8 +265,17 @@ public final class SearchService: ObservableObject { } private func getItemNameSuggestions(for query: String) async -> [SearchSuggestion] { - // TODO: Implement actual item name suggestions from storage - return [] + guard let provider = searchProvider else { + // Return empty array if no provider is available + return [] + } + + do { + return try await provider.getItemSuggestions(for: query, limit: 5) + } catch { + // Return empty array on error + return [] + } } private func removeDuplicates(_ results: [SearchResult]) -> [SearchResult] { diff --git a/Services-Search/Sources/ServicesSearch/SearchServiceFactory.swift b/Services-Search/Sources/ServicesSearch/SearchServiceFactory.swift new file mode 100644 index 00000000..8e0533ce --- /dev/null +++ b/Services-Search/Sources/ServicesSearch/SearchServiceFactory.swift @@ -0,0 +1,35 @@ +import Foundation +import FoundationCore +import FoundationModels +import InfrastructureStorage + +/// Factory for creating SearchService instances with proper dependencies +@MainActor +public enum SearchServiceFactory { + + /// Create a SearchService with infrastructure dependencies + /// - Parameters: + /// - searchHistoryRepository: Repository for managing search history + /// - savedSearchRepository: Repository for managing saved searches + /// - itemRepository: Repository for accessing inventory items + /// - Returns: Configured SearchService instance + public static func create( + searchHistoryRepository: SearchHistoryRepository, + savedSearchRepository: SavedSearchRepository, + itemRepository: ItemRepository + ) -> SearchService { + let provider = DefaultSearchProvider( + searchHistoryRepository: searchHistoryRepository, + savedSearchRepository: savedSearchRepository, + itemRepository: itemRepository + ) + + return SearchService(searchProvider: provider) + } + + /// Create a SearchService with default UserDefaults-based implementation + /// - Returns: SearchService instance without infrastructure dependencies + public static func createDefault() -> SearchService { + return SearchService(searchProvider: nil) + } +} \ No newline at end of file diff --git a/Services-Search/Tests/ServicesSearchTests/SearchProviderTests.swift b/Services-Search/Tests/ServicesSearchTests/SearchProviderTests.swift new file mode 100644 index 00000000..48acf971 --- /dev/null +++ b/Services-Search/Tests/ServicesSearchTests/SearchProviderTests.swift @@ -0,0 +1,243 @@ +import XCTest +@testable import ServicesSearch +import FoundationCore +import FoundationModels +import InfrastructureStorage + +@MainActor +final class SearchProviderTests: XCTestCase { + + private var sut: DefaultSearchProvider! + private var mockSearchHistoryRepo: MockSearchHistoryRepository! + private var mockSavedSearchRepo: MockSavedSearchRepository! + private var mockItemRepo: MockItemRepository! + + override func setUp() async throws { + try await super.setUp() + + mockSearchHistoryRepo = MockSearchHistoryRepository() + mockSavedSearchRepo = MockSavedSearchRepository() + mockItemRepo = MockItemRepository() + + sut = DefaultSearchProvider( + searchHistoryRepository: mockSearchHistoryRepo, + savedSearchRepository: mockSavedSearchRepo, + itemRepository: mockItemRepo + ) + } + + override func tearDown() async throws { + sut = nil + mockSearchHistoryRepo = nil + mockSavedSearchRepo = nil + mockItemRepo = nil + + try await super.tearDown() + } + + func testFetchSearchHistory() async throws { + // Given + let expectedHistory = [ + SearchHistory(id: UUID(), query: "iPhone", timestamp: Date(), resultCount: 5), + SearchHistory(id: UUID(), query: "MacBook", timestamp: Date(), resultCount: 3) + ] + mockSearchHistoryRepo.historyToReturn = expectedHistory + + // When + let history = try await sut.fetchSearchHistory() + + // Then + XCTAssertEqual(history.count, 2) + XCTAssertEqual(history[0], "iPhone") + XCTAssertEqual(history[1], "MacBook") + } + + func testSaveSearchQuery() async throws { + // Given + let query = "Test Query" + let resultCount = 10 + + // When + try await sut.saveSearchQuery(query, resultCount: resultCount) + + // Then + XCTAssertTrue(mockSearchHistoryRepo.saveSearchCalled) + XCTAssertEqual(mockSearchHistoryRepo.lastSavedQuery, query) + XCTAssertEqual(mockSearchHistoryRepo.lastSavedResultCount, resultCount) + } + + func testClearSearchHistory() async throws { + // When + try await sut.clearSearchHistory() + + // Then + XCTAssertTrue(mockSearchHistoryRepo.deleteAllCalled) + } + + func testGetSuggestions() async throws { + // Given + let partialQuery = "iPh" + let expectedSuggestions = ["iPhone", "iPhone Pro", "iPhone Mini"] + mockSearchHistoryRepo.suggestionsToReturn = expectedSuggestions + + // When + let suggestions = try await sut.getSuggestions(for: partialQuery) + + // Then + XCTAssertEqual(suggestions, expectedSuggestions) + XCTAssertEqual(mockSearchHistoryRepo.lastSuggestionsQuery, partialQuery) + } + + func testGetItemSuggestions() async throws { + // Given + let query = "MacBook" + let mockItems = [ + InventoryItem(name: "MacBook Pro", category: .electronics), + InventoryItem(name: "MacBook Air", category: .electronics) + ] + mockItemRepo.itemsToReturn = mockItems + + // When + let suggestions = try await sut.getItemSuggestions(for: query, limit: 5) + + // Then + XCTAssertEqual(suggestions.count, 2) + XCTAssertEqual(suggestions[0].text, "MacBook Pro") + XCTAssertEqual(suggestions[0].type, .item) + XCTAssertEqual(suggestions[1].text, "MacBook Air") + } +} + +// MARK: - Mock Repositories + +class MockSearchHistoryRepository: SearchHistoryRepository { + var historyToReturn: [SearchHistory] = [] + var suggestionsToReturn: [String] = [] + var saveSearchCalled = false + var deleteAllCalled = false + var lastSavedQuery: String? + var lastSavedResultCount: Int? + var lastSuggestionsQuery: String? + + func fetchAll() async throws -> [SearchHistory] { + return historyToReturn + } + + func fetchRecent(limit: Int) async throws -> [SearchHistory] { + return Array(historyToReturn.prefix(limit)) + } + + func fetchHistory(for query: String) async throws -> [SearchHistory] { + return historyToReturn.filter { $0.query.contains(query) } + } + + func saveSearch(_ query: String, resultCount: Int, timestamp: Date) async throws { + saveSearchCalled = true + lastSavedQuery = query + lastSavedResultCount = resultCount + } + + func delete(_ history: SearchHistory) async throws { + historyToReturn.removeAll { $0.id == history.id } + } + + func deleteAll() async throws { + deleteAllCalled = true + historyToReturn = [] + } + + func deleteOlderThan(_ date: Date) async throws { + historyToReturn.removeAll { $0.timestamp < date } + } + + func getSuggestions(for partialQuery: String, limit: Int) async throws -> [String] { + lastSuggestionsQuery = partialQuery + return Array(suggestionsToReturn.prefix(limit)) + } + + var historyPublisher: AnyPublisher<[SearchHistory], Never> { + Just(historyToReturn).eraseToAnyPublisher() + } +} + +class MockSavedSearchRepository: SavedSearchRepository { + var savedSearchesToReturn: [SavedSearch] = [] + + func fetchAll() async throws -> [SavedSearch] { + return savedSearchesToReturn + } + + func fetchPinned() async throws -> [SavedSearch] { + return savedSearchesToReturn.filter { $0.isPinned } + } + + func save(_ search: SavedSearch) async throws { + savedSearchesToReturn.append(search) + } + + func update(_ search: SavedSearch) async throws { + // Update implementation + } + + func delete(_ search: SavedSearch) async throws { + savedSearchesToReturn.removeAll { $0.id == search.id } + } + + func deleteAll() async throws { + savedSearchesToReturn = [] + } + + func recordUsage(of search: SavedSearch) async throws { + // Record usage implementation + } + + var savedSearchesPublisher: AnyPublisher<[SavedSearch], Never> { + Just(savedSearchesToReturn).eraseToAnyPublisher() + } +} + +class MockItemRepository: BaseRepository, ItemRepository { + var itemsToReturn: [InventoryItem] = [] + + override func fetchAll() async throws -> [InventoryItem] { + return itemsToReturn + } + + func search(query: String) async throws -> [InventoryItem] { + return itemsToReturn.filter { item in + item.name.localizedCaseInsensitiveContains(query) + } + } + + func fuzzySearch(query: String, fuzzyService: FuzzySearchService) async throws -> [InventoryItem] { + return try await search(query: query) + } + + func fuzzySearch(query: String, threshold: Float) async throws -> [InventoryItem] { + return try await search(query: query) + } + + func fetchByCategory(_ category: ItemCategory) async throws -> [InventoryItem] { + return itemsToReturn.filter { $0.category == category } + } + + func fetchByLocation(_ location: Location) async throws -> [InventoryItem] { + return itemsToReturn.filter { $0.locationId == location.id } + } + + func fetchRecentlyViewed(limit: Int) async throws -> [InventoryItem] { + return Array(itemsToReturn.prefix(limit)) + } + + func fetchByTag(_ tag: String) async throws -> [InventoryItem] { + return itemsToReturn.filter { $0.tags.contains(tag) } + } + + func fetchInDateRange(from: Date, to: Date) async throws -> [InventoryItem] { + return itemsToReturn + } + + func updateAll(_ items: [InventoryItem]) async throws { + // Update implementation + } +} \ No newline at end of file From 5349a3d728d34787ab912ffbe837c0d30ea48e06 Mon Sep 17 00:00:00 2001 From: DrunkOnJava <151978260+DrunkOnJava@users.noreply.github.com> Date: Sat, 26 Jul 2025 20:05:29 +0000 Subject: [PATCH 2/2] fix: Add missing Combine import and BaseRepository initialization - Added import Combine to SearchProviderTests for AnyPublisher usage - Fixed MockItemRepository initialization by adding proper init() method - Resolves compilation errors identified in PR review --- .../Tests/ServicesSearchTests/SearchProviderTests.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Services-Search/Tests/ServicesSearchTests/SearchProviderTests.swift b/Services-Search/Tests/ServicesSearchTests/SearchProviderTests.swift index 48acf971..b3f16a97 100644 --- a/Services-Search/Tests/ServicesSearchTests/SearchProviderTests.swift +++ b/Services-Search/Tests/ServicesSearchTests/SearchProviderTests.swift @@ -3,6 +3,7 @@ import XCTest import FoundationCore import FoundationModels import InfrastructureStorage +import Combine @MainActor final class SearchProviderTests: XCTestCase { @@ -199,6 +200,10 @@ class MockSavedSearchRepository: SavedSearchRepository { class MockItemRepository: BaseRepository, ItemRepository { var itemsToReturn: [InventoryItem] = [] + init() { + super.init() + } + override func fetchAll() async throws -> [InventoryItem] { return itemsToReturn }