JobSearchPipelineTests.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. //
  2. // JobSearchPipelineTests.swift
  3. // indeed_ai_job_searchTests
  4. //
  5. import XCTest
  6. @testable import indeed_ai_job_search
  7. final class JobListingFilterTests: XCTestCase {
  8. func testExcludesUnavailableAndExcludedKeywords() {
  9. let open = makeListing(key: "a", title: "iOS Developer", snippet: "SwiftUI")
  10. var closed = makeListing(key: "b", title: "iOS Engineer", snippet: "UIKit")
  11. closed.availability = .unavailable
  12. let excluded = makeListing(key: "c", title: "iOS Developer", snippet: "agency contract")
  13. var criteria = SearchCriteria()
  14. criteria.jobTitle = "iOS"
  15. criteria.excludedKeywords = "agency"
  16. let filtered = JobListingFilter.filter(
  17. [open, closed, excluded],
  18. criteria: criteria,
  19. query: "iOS"
  20. )
  21. XCTAssertEqual(filtered.map(\.indeedJobKey), ["a"])
  22. }
  23. func testAppliesDateFreshness() {
  24. let recent = makeListing(
  25. key: "new",
  26. title: "Swift Developer",
  27. postedAt: Date().addingTimeInterval(-86_400)
  28. )
  29. let stale = makeListing(
  30. key: "old",
  31. title: "Swift Developer",
  32. postedAt: Date().addingTimeInterval(-86_400 * 40)
  33. )
  34. var criteria = SearchCriteria()
  35. criteria.keywords = "Swift"
  36. criteria.dateFreshnessDays = 14
  37. let filtered = JobListingFilter.filter([recent, stale], criteria: criteria, query: "")
  38. XCTAssertEqual(filtered.map(\.indeedJobKey), ["new"])
  39. }
  40. }
  41. final class JobListingDeduperTests: XCTestCase {
  42. func testCollapsesByIndeedJobKeyKeepingNewest() {
  43. let older = makeListing(
  44. key: "jk1",
  45. title: "Old title",
  46. postedAt: Date().addingTimeInterval(-10_000)
  47. )
  48. let newer = makeListing(
  49. key: "jk1",
  50. title: "New title",
  51. postedAt: Date()
  52. )
  53. let other = makeListing(key: "jk2", title: "Other")
  54. let result = JobListingDeduper.dedupe([older, other, newer])
  55. XCTAssertEqual(result.count, 2)
  56. XCTAssertEqual(result.first?.title, "New title")
  57. XCTAssertEqual(result.map(\.indeedJobKey), ["jk1", "jk2"])
  58. }
  59. }
  60. final class JobRankingApplyOrderTests: XCTestCase {
  61. func testApplyOrderPreservesOmittedKeys() {
  62. let a = makeListing(key: "a", title: "A")
  63. let b = makeListing(key: "b", title: "B")
  64. let c = makeListing(key: "c", title: "C")
  65. let ordered = OpenAIJobRankingService.applyOrder(["c", "a"], to: [a, b, c])
  66. XCTAssertEqual(ordered.map(\.indeedJobKey), ["c", "a", "b"])
  67. }
  68. }
  69. final class SearchCriteriaMergeTests: XCTestCase {
  70. func testUserEditsWinOverInterpretation() {
  71. var interpreted = SearchCriteria()
  72. interpreted.jobTitle = "Android"
  73. interpreted.country = "USA"
  74. var user = SearchCriteria()
  75. user.country = "" // unset so AI country can apply
  76. user.jobTitle = "iOS Developer"
  77. user.remoteTypes = [.remote]
  78. let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user)
  79. XCTAssertEqual(merged.jobTitle, "iOS Developer")
  80. XCTAssertEqual(merged.country, "USA")
  81. XCTAssertEqual(merged.remoteTypes, [.remote])
  82. }
  83. func testEmptyUserCountryKeepsAICountry() {
  84. var interpreted = SearchCriteria()
  85. interpreted.country = "United Kingdom"
  86. // No country default — empty user filter must not overwrite AI geo from the query.
  87. let user = SearchCriteria()
  88. XCTAssertTrue(user.country.isEmpty)
  89. let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user)
  90. XCTAssertEqual(merged.country, "United Kingdom")
  91. }
  92. func testUserCountryFilterOverridesAI() {
  93. var interpreted = SearchCriteria()
  94. interpreted.country = "United Kingdom"
  95. var user = SearchCriteria()
  96. user.country = "Pakistan"
  97. let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user)
  98. XCTAssertEqual(merged.country, "Pakistan")
  99. }
  100. }
  101. final class IndeedBrowseURLBuilderTests: XCTestCase {
  102. func testFakeJobKeyUsesBrowseSearchURL() {
  103. let listing = JobListing(
  104. indeedJobKey: "fixture-ios-001",
  105. title: "iOS Developer",
  106. company: "Acme",
  107. location: "Lahore, Pakistan",
  108. remoteType: .remote,
  109. postedAt: Date(),
  110. snippet: "Swift",
  111. indeedURL: URL(string: "https://pk.indeed.com/viewjob?jk=fixture-ios-001")!,
  112. availability: .open
  113. )
  114. let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
  115. XCTAssertTrue(resolved.usedBrowseFallback)
  116. XCTAssertTrue(resolved.url.path.contains("/jobs"))
  117. XCTAssertEqual(resolved.url.host, "pk.indeed.com")
  118. XCTAssertFalse(resolved.url.absoluteString.contains("viewjob"))
  119. // Company must be in `q` so Indeed does not show an unrelated employer.
  120. let q = URLComponents(url: resolved.url, resolvingAgainstBaseURL: false)?
  121. .queryItems?
  122. .first { $0.name == "q" }?
  123. .value ?? ""
  124. XCTAssertTrue(q.contains("company:Acme"), "expected company filter in q, got \(q)")
  125. XCTAssertTrue(q.contains("iOS Developer"))
  126. }
  127. func testSearchQueryQuotesMultiWordCompany() {
  128. let q = IndeedBrowseURLBuilder.searchQuery(title: "iOS Developer", company: "Systems Limited")
  129. XCTAssertEqual(q, "iOS Developer company:\"Systems Limited\"")
  130. }
  131. func testRealHexJobKeyKeepsViewJob() {
  132. let listing = JobListing(
  133. indeedJobKey: "5185d0dd8c286093",
  134. title: "Senior Software Engineer",
  135. company: "Acme",
  136. location: "New York, NY",
  137. remoteType: .remote,
  138. postedAt: Date(),
  139. snippet: "Swift",
  140. indeedURL: URL(string: "https://www.indeed.com/viewjob?jk=5185d0dd8c286093")!,
  141. availability: .open
  142. )
  143. let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
  144. XCTAssertFalse(resolved.usedBrowseFallback)
  145. XCTAssertTrue(resolved.url.absoluteString.contains("viewjob"))
  146. }
  147. }
  148. final class DefaultJobSearchPipelineTests: XCTestCase {
  149. func testPipelineFiltersDedupesAndRanks() async throws {
  150. let duplicateOlder = makeListing(
  151. key: "dup",
  152. title: "iOS Developer",
  153. postedAt: Date().addingTimeInterval(-5_000)
  154. )
  155. let duplicateNewer = makeListing(
  156. key: "dup",
  157. title: "iOS Developer (Updated)",
  158. postedAt: Date()
  159. )
  160. let android = makeListing(key: "and", title: "Android Developer")
  161. let source = InlinePipelineDataSource(listings: [duplicateOlder, android, duplicateNewer])
  162. let pipeline = DefaultJobSearchPipeline(
  163. interpreter: StaticInterpreter(
  164. InterpretedSearch(
  165. naturalLanguageQuery: "iOS",
  166. criteria: {
  167. var c = SearchCriteria()
  168. c.jobTitle = "iOS"
  169. return c
  170. }(),
  171. confidenceNote: "test"
  172. )
  173. ),
  174. dataSource: source,
  175. rankingService: HeuristicJobRankingService(),
  176. validationService: CompositeJobValidationService(
  177. source: SourceSignalJobValidationService(),
  178. http: nil
  179. ),
  180. cache: NullJobListingCache()
  181. )
  182. let previousValidation = FeatureFlags.isListingValidationEnabled
  183. let previousRanking = FeatureFlags.isAIRankingEnabled
  184. FeatureFlags.isListingValidationEnabled = true
  185. FeatureFlags.isAIRankingEnabled = false
  186. defer {
  187. FeatureFlags.isListingValidationEnabled = previousValidation
  188. FeatureFlags.isAIRankingEnabled = previousRanking
  189. }
  190. let result = try await pipeline.search(
  191. JobSearchPipelineRequest(
  192. naturalLanguageQuery: "iOS jobs",
  193. userCriteria: SearchCriteria(),
  194. reinterpret: true
  195. )
  196. )
  197. XCTAssertEqual(result.listings.count, 1)
  198. XCTAssertEqual(result.listings.first?.indeedJobKey, "dup")
  199. XCTAssertEqual(result.listings.first?.title, "iOS Developer (Updated)")
  200. XCTAssertFalse(result.usedCache)
  201. }
  202. func testCacheHitSkipsRefetch() async throws {
  203. let listing = makeListing(key: "cached", title: "iOS Developer")
  204. let source = InlinePipelineDataSource(listings: [listing])
  205. let cache = InMemoryIndeedListingCache(ttl: 60)
  206. let pipeline = DefaultJobSearchPipeline(
  207. interpreter: StaticInterpreter(
  208. InterpretedSearch(
  209. naturalLanguageQuery: "iOS",
  210. criteria: {
  211. var c = SearchCriteria()
  212. c.keywords = "iOS"
  213. return c
  214. }(),
  215. confidenceNote: "test"
  216. )
  217. ),
  218. dataSource: source,
  219. rankingService: HeuristicJobRankingService(),
  220. validationService: CompositeJobValidationService(
  221. source: SourceSignalJobValidationService(),
  222. http: nil
  223. ),
  224. cache: cache
  225. )
  226. let request = JobSearchPipelineRequest(
  227. naturalLanguageQuery: "iOS",
  228. userCriteria: SearchCriteria(),
  229. reinterpret: true
  230. )
  231. _ = try await pipeline.search(request)
  232. let second = try await pipeline.search(request)
  233. XCTAssertTrue(second.usedCache)
  234. XCTAssertEqual(source.fetchCount, 1)
  235. }
  236. func testProgressiveYieldsPrimaryThenAppendsEnrichment() async throws {
  237. let primaryA = makeListing(key: "web1", title: "iOS Developer")
  238. let primaryB = makeListing(key: "web2", title: "iOS Engineer")
  239. let scrapeDup = makeListing(key: "web1", title: "iOS Developer Duplicate")
  240. let scrapeNew = makeListing(key: "live1", title: "Swift iOS Developer")
  241. let primary = InlinePipelineDataSource(listings: [primaryA, primaryB])
  242. let enrichment = InlinePipelineDataSource(listings: [scrapeDup, scrapeNew])
  243. let pipeline = DefaultJobSearchPipeline(
  244. interpreter: StaticInterpreter(
  245. InterpretedSearch(
  246. naturalLanguageQuery: "iOS",
  247. criteria: {
  248. var c = SearchCriteria()
  249. c.jobTitle = "iOS"
  250. return c
  251. }(),
  252. confidenceNote: "test"
  253. )
  254. ),
  255. dataSource: primary,
  256. enrichmentDataSource: enrichment,
  257. rankingService: HeuristicJobRankingService(),
  258. validationService: CompositeJobValidationService(
  259. source: SourceSignalJobValidationService(),
  260. http: nil
  261. ),
  262. cache: NullJobListingCache()
  263. )
  264. let previousValidation = FeatureFlags.isListingValidationEnabled
  265. let previousRanking = FeatureFlags.isAIRankingEnabled
  266. FeatureFlags.isListingValidationEnabled = true
  267. FeatureFlags.isAIRankingEnabled = false
  268. defer {
  269. FeatureFlags.isListingValidationEnabled = previousValidation
  270. FeatureFlags.isAIRankingEnabled = previousRanking
  271. }
  272. var phases: [JobSearchPipelineProgress.Phase] = []
  273. var lastKeys: [String] = []
  274. for try await progress in pipeline.searchProgressive(
  275. JobSearchPipelineRequest(
  276. naturalLanguageQuery: "iOS jobs",
  277. userCriteria: SearchCriteria(),
  278. reinterpret: true
  279. )
  280. ) {
  281. phases.append(progress.phase)
  282. lastKeys = progress.listings.map(\.indeedJobKey)
  283. if progress.phase == .partial {
  284. XCTAssertTrue(progress.isLoadingMore)
  285. XCTAssertEqual(Set(progress.listings.map(\.indeedJobKey)), Set(["web1", "web2"]))
  286. }
  287. }
  288. XCTAssertEqual(phases, [.partial, .complete])
  289. // Primary web results stay on top; scrape-only keys append after.
  290. XCTAssertEqual(Set(lastKeys.prefix(2)), Set(["web1", "web2"]))
  291. XCTAssertEqual(lastKeys.last, "live1")
  292. XCTAssertEqual(Set(lastKeys), Set(["web1", "web2", "live1"]))
  293. XCTAssertEqual(primary.fetchCount, 1)
  294. XCTAssertEqual(enrichment.fetchCount, 1)
  295. }
  296. }
  297. final class OpenAIModelsTests: XCTestCase {
  298. func testDefaultModelIsGPT6Luna() {
  299. XCTAssertEqual(OpenAIModels.defaultChatModel, "gpt-6-luna")
  300. let client = OpenAIClient(apiKey: "sk-test")
  301. XCTAssertEqual(client.model, "gpt-6-luna")
  302. XCTAssertEqual(client.reasoningEffort, "none")
  303. }
  304. }
  305. // MARK: - Helpers
  306. private func makeListing(
  307. key: String,
  308. title: String,
  309. snippet: String = "Build apps",
  310. postedAt: Date = Date()
  311. ) -> JobListing {
  312. JobListing(
  313. indeedJobKey: key,
  314. title: title,
  315. company: "Acme",
  316. location: "Lahore, Pakistan",
  317. remoteType: .remote,
  318. employmentType: .fullTime,
  319. experienceLevel: .mid,
  320. postedAt: postedAt,
  321. snippet: snippet,
  322. indeedURL: URL(string: "https://pk.indeed.com/viewjob?jk=\(key)")!,
  323. availability: .open
  324. )
  325. }
  326. private struct StaticInterpreter: OpenAISearchService {
  327. let value: InterpretedSearch
  328. init(_ value: InterpretedSearch) { self.value = value }
  329. func interpret(query: String) async throws -> InterpretedSearch { value }
  330. }
  331. private final class InlinePipelineDataSource: IndeedJobDataSource, @unchecked Sendable {
  332. let listings: [JobListing]
  333. private(set) var fetchCount = 0
  334. init(listings: [JobListing]) {
  335. self.listings = listings
  336. }
  337. func searchPage(
  338. criteria: SearchCriteria,
  339. query: String,
  340. page: IndeedSearchPageRequest
  341. ) async throws -> IndeedSearchPage {
  342. fetchCount += 1
  343. return IndeedSearchPage(
  344. listings: listings,
  345. pageIndex: page.pageIndex,
  346. pageSize: page.pageSize,
  347. totalEstimated: listings.count,
  348. nextCursor: nil,
  349. hasMore: false
  350. )
  351. }
  352. }