| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- //
- // 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")
- }
- /// New search-box queries clear title/keywords before merge so the latest
- /// interpreted role wins instead of a stale AI-filled filter title.
- func testClearedTitleLetsNewInterpretationWin() {
- var interpreted = SearchCriteria()
- interpreted.jobTitle = "DevOps Engineer"
- interpreted.country = "Pakistan"
- var user = SearchCriteria()
- user.jobTitle = "" // cleared by submitSearch
- user.keywords = ""
- user.country = "Pakistan"
- user.remoteTypes = [.remote]
- let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user)
- XCTAssertEqual(merged.jobTitle, "DevOps Engineer")
- XCTAssertEqual(merged.country, "Pakistan")
- XCTAssertEqual(merged.remoteTypes, [.remote])
- }
- }
- final class IndeedBrowseURLBuilderTests: XCTestCase {
- func testFakeJobKeyOpensStoredIndeedURL() {
- 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)
- XCTAssertFalse(resolved.usedBrowseFallback)
- XCTAssertEqual(resolved.url.host, "pk.indeed.com")
- XCTAssertTrue(resolved.url.absoluteString.contains("viewjob"))
- XCTAssertFalse(resolved.url.path.contains("/jobs"))
- }
- 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)
- }
- func testProgressiveYieldsPrimaryThenAppendsEnrichment() async throws {
- let primaryA = makeListing(key: "web1", title: "iOS Developer")
- let primaryB = makeListing(key: "web2", title: "iOS Engineer")
- let scrapeDup = makeListing(key: "web1", title: "iOS Developer Duplicate")
- let scrapeNew = makeListing(key: "live1", title: "Swift iOS Developer")
- let primary = InlinePipelineDataSource(listings: [primaryA, primaryB])
- let enrichment = InlinePipelineDataSource(listings: [scrapeDup, scrapeNew])
- let pipeline = DefaultJobSearchPipeline(
- interpreter: StaticInterpreter(
- InterpretedSearch(
- naturalLanguageQuery: "iOS",
- criteria: {
- var c = SearchCriteria()
- c.jobTitle = "iOS"
- return c
- }(),
- confidenceNote: "test"
- )
- ),
- dataSource: primary,
- enrichmentDataSource: enrichment,
- 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
- }
- var phases: [JobSearchPipelineProgress.Phase] = []
- var lastKeys: [String] = []
- for try await progress in pipeline.searchProgressive(
- JobSearchPipelineRequest(
- naturalLanguageQuery: "iOS jobs",
- userCriteria: SearchCriteria(),
- reinterpret: true
- )
- ) {
- phases.append(progress.phase)
- lastKeys = progress.listings.map(\.indeedJobKey)
- if progress.phase == .partial {
- XCTAssertTrue(progress.isLoadingMore)
- XCTAssertEqual(Set(progress.listings.map(\.indeedJobKey)), Set(["web1", "web2"]))
- }
- }
- XCTAssertEqual(phases, [.partial, .complete])
- // Primary web results stay on top; scrape-only keys append after.
- XCTAssertEqual(Set(lastKeys.prefix(2)), Set(["web1", "web2"]))
- XCTAssertEqual(lastKeys.last, "live1")
- XCTAssertEqual(Set(lastKeys), Set(["web1", "web2", "live1"]))
- XCTAssertEqual(primary.fetchCount, 1)
- XCTAssertEqual(enrichment.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
- )
- }
- }
|