// // JobSearchPipelineTests.swift // indeed_ai_job_searchTests // import XCTest @testable import indeed_ai_job_search final class JobListingFilterTests: XCTestCase { func testExcludesUnavailableAndExcludedKeywords() { let open = makeListing(key: "a", title: "iOS Developer", snippet: "SwiftUI") var closed = makeListing(key: "b", title: "iOS Engineer", snippet: "UIKit") closed.availability = .unavailable let excluded = makeListing(key: "c", title: "iOS Developer", snippet: "agency contract") var criteria = SearchCriteria() criteria.jobTitle = "iOS" criteria.excludedKeywords = "agency" let filtered = JobListingFilter.filter( [open, closed, excluded], criteria: criteria, query: "iOS" ) XCTAssertEqual(filtered.map(\.indeedJobKey), ["a"]) } func testAppliesDateFreshness() { let recent = makeListing( key: "new", title: "Swift Developer", postedAt: Date().addingTimeInterval(-86_400) ) let stale = makeListing( key: "old", title: "Swift Developer", postedAt: Date().addingTimeInterval(-86_400 * 40) ) var criteria = SearchCriteria() criteria.keywords = "Swift" criteria.dateFreshnessDays = 14 let filtered = JobListingFilter.filter([recent, stale], criteria: criteria, query: "") XCTAssertEqual(filtered.map(\.indeedJobKey), ["new"]) } } final class JobListingDeduperTests: XCTestCase { func testCollapsesByIndeedJobKeyKeepingNewest() { let older = makeListing( key: "jk1", title: "Old title", postedAt: Date().addingTimeInterval(-10_000) ) let newer = makeListing( key: "jk1", title: "New title", postedAt: Date() ) let other = makeListing(key: "jk2", title: "Other") let result = JobListingDeduper.dedupe([older, other, newer]) XCTAssertEqual(result.count, 2) XCTAssertEqual(result.first?.title, "New title") XCTAssertEqual(result.map(\.indeedJobKey), ["jk1", "jk2"]) } } final class JobRankingApplyOrderTests: XCTestCase { func testApplyOrderPreservesOmittedKeys() { let a = makeListing(key: "a", title: "A") let b = makeListing(key: "b", title: "B") let c = makeListing(key: "c", title: "C") let ordered = OpenAIJobRankingService.applyOrder(["c", "a"], to: [a, b, c]) XCTAssertEqual(ordered.map(\.indeedJobKey), ["c", "a", "b"]) } } final class SearchCriteriaMergeTests: XCTestCase { func testUserEditsWinOverInterpretation() { var interpreted = SearchCriteria() interpreted.jobTitle = "Android" interpreted.country = "USA" var user = SearchCriteria() user.country = "" // unset so AI country can apply user.jobTitle = "iOS Developer" user.remoteTypes = [.remote] let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user) XCTAssertEqual(merged.jobTitle, "iOS Developer") XCTAssertEqual(merged.country, "USA") XCTAssertEqual(merged.remoteTypes, [.remote]) } func testEmptyUserCountryKeepsAICountry() { var interpreted = SearchCriteria() interpreted.country = "United Kingdom" // No country default — empty user filter must not overwrite AI geo from the query. let user = SearchCriteria() XCTAssertTrue(user.country.isEmpty) let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user) XCTAssertEqual(merged.country, "United Kingdom") } func testUserCountryFilterOverridesAI() { var interpreted = SearchCriteria() interpreted.country = "United Kingdom" var user = SearchCriteria() user.country = "Pakistan" let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user) XCTAssertEqual(merged.country, "Pakistan") } } final class IndeedBrowseURLBuilderTests: XCTestCase { func testFakeJobKeyUsesBrowseSearchURL() { let listing = JobListing( indeedJobKey: "fixture-ios-001", title: "iOS Developer", company: "Acme", location: "Lahore, Pakistan", remoteType: .remote, postedAt: Date(), snippet: "Swift", indeedURL: URL(string: "https://pk.indeed.com/viewjob?jk=fixture-ios-001")!, availability: .open ) let resolved = IndeedBrowseURLBuilder.openableURL(for: listing) XCTAssertTrue(resolved.usedBrowseFallback) XCTAssertTrue(resolved.url.path.contains("/jobs")) XCTAssertEqual(resolved.url.host, "pk.indeed.com") XCTAssertFalse(resolved.url.absoluteString.contains("viewjob")) // Company must be in `q` so Indeed does not show an unrelated employer. let q = URLComponents(url: resolved.url, resolvingAgainstBaseURL: false)? .queryItems? .first { $0.name == "q" }? .value ?? "" XCTAssertTrue(q.contains("company:Acme"), "expected company filter in q, got \(q)") XCTAssertTrue(q.contains("iOS Developer")) } func testSearchQueryQuotesMultiWordCompany() { let q = IndeedBrowseURLBuilder.searchQuery(title: "iOS Developer", company: "Systems Limited") XCTAssertEqual(q, "iOS Developer company:\"Systems Limited\"") } func testRealHexJobKeyKeepsViewJob() { let listing = JobListing( indeedJobKey: "5185d0dd8c286093", title: "Senior Software Engineer", company: "Acme", location: "New York, NY", remoteType: .remote, postedAt: Date(), snippet: "Swift", indeedURL: URL(string: "https://www.indeed.com/viewjob?jk=5185d0dd8c286093")!, availability: .open ) let resolved = IndeedBrowseURLBuilder.openableURL(for: listing) XCTAssertFalse(resolved.usedBrowseFallback) XCTAssertTrue(resolved.url.absoluteString.contains("viewjob")) } } final class DefaultJobSearchPipelineTests: XCTestCase { func testPipelineFiltersDedupesAndRanks() async throws { let duplicateOlder = makeListing( key: "dup", title: "iOS Developer", postedAt: Date().addingTimeInterval(-5_000) ) let duplicateNewer = makeListing( key: "dup", title: "iOS Developer (Updated)", postedAt: Date() ) let android = makeListing(key: "and", title: "Android Developer") let source = InlinePipelineDataSource(listings: [duplicateOlder, android, duplicateNewer]) let pipeline = DefaultJobSearchPipeline( interpreter: StaticInterpreter( InterpretedSearch( naturalLanguageQuery: "iOS", criteria: { var c = SearchCriteria() c.jobTitle = "iOS" return c }(), confidenceNote: "test" ) ), dataSource: source, rankingService: HeuristicJobRankingService(), validationService: CompositeJobValidationService( source: SourceSignalJobValidationService(), http: nil ), cache: NullJobListingCache() ) let previousValidation = FeatureFlags.isListingValidationEnabled let previousRanking = FeatureFlags.isAIRankingEnabled FeatureFlags.isListingValidationEnabled = true FeatureFlags.isAIRankingEnabled = false defer { FeatureFlags.isListingValidationEnabled = previousValidation FeatureFlags.isAIRankingEnabled = previousRanking } let result = try await pipeline.search( JobSearchPipelineRequest( naturalLanguageQuery: "iOS jobs", userCriteria: SearchCriteria(), reinterpret: true ) ) XCTAssertEqual(result.listings.count, 1) XCTAssertEqual(result.listings.first?.indeedJobKey, "dup") XCTAssertEqual(result.listings.first?.title, "iOS Developer (Updated)") XCTAssertFalse(result.usedCache) } func testCacheHitSkipsRefetch() async throws { let listing = makeListing(key: "cached", title: "iOS Developer") let source = InlinePipelineDataSource(listings: [listing]) let cache = InMemoryIndeedListingCache(ttl: 60) let pipeline = DefaultJobSearchPipeline( interpreter: StaticInterpreter( InterpretedSearch( naturalLanguageQuery: "iOS", criteria: { var c = SearchCriteria() c.keywords = "iOS" return c }(), confidenceNote: "test" ) ), dataSource: source, rankingService: HeuristicJobRankingService(), validationService: CompositeJobValidationService( source: SourceSignalJobValidationService(), http: nil ), cache: cache ) let request = JobSearchPipelineRequest( naturalLanguageQuery: "iOS", userCriteria: SearchCriteria(), reinterpret: true ) _ = try await pipeline.search(request) let second = try await pipeline.search(request) XCTAssertTrue(second.usedCache) XCTAssertEqual(source.fetchCount, 1) } } final class OpenAIModelsTests: XCTestCase { func testDefaultModelIsGPT6Luna() { XCTAssertEqual(OpenAIModels.defaultChatModel, "gpt-6-luna") let client = OpenAIClient(apiKey: "sk-test") XCTAssertEqual(client.model, "gpt-6-luna") XCTAssertEqual(client.reasoningEffort, "none") } } // MARK: - Helpers private func makeListing( key: String, title: String, snippet: String = "Build apps", postedAt: Date = Date() ) -> JobListing { JobListing( indeedJobKey: key, title: title, company: "Acme", location: "Lahore, Pakistan", remoteType: .remote, employmentType: .fullTime, experienceLevel: .mid, postedAt: postedAt, snippet: snippet, indeedURL: URL(string: "https://pk.indeed.com/viewjob?jk=\(key)")!, availability: .open ) } private struct StaticInterpreter: OpenAISearchService { let value: InterpretedSearch init(_ value: InterpretedSearch) { self.value = value } func interpret(query: String) async throws -> InterpretedSearch { value } } private final class InlinePipelineDataSource: IndeedJobDataSource, @unchecked Sendable { let listings: [JobListing] private(set) var fetchCount = 0 init(listings: [JobListing]) { self.listings = listings } func searchPage( criteria: SearchCriteria, query: String, page: IndeedSearchPageRequest ) async throws -> IndeedSearchPage { fetchCount += 1 return IndeedSearchPage( listings: listings, pageIndex: page.pageIndex, pageSize: page.pageSize, totalEstimated: listings.count, nextCursor: nil, hasMore: false ) } }