JobSearchPipelineTests.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  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. /// New search-box queries clear title/keywords before merge so the latest
  101. /// interpreted role wins instead of a stale AI-filled filter title.
  102. func testClearedTitleLetsNewInterpretationWin() {
  103. var interpreted = SearchCriteria()
  104. interpreted.jobTitle = "DevOps Engineer"
  105. interpreted.country = "Pakistan"
  106. var user = SearchCriteria()
  107. user.jobTitle = "" // cleared by submitSearch
  108. user.keywords = ""
  109. user.country = "Pakistan"
  110. user.remoteTypes = [.remote]
  111. let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user)
  112. XCTAssertEqual(merged.jobTitle, "DevOps Engineer")
  113. XCTAssertEqual(merged.country, "Pakistan")
  114. XCTAssertEqual(merged.remoteTypes, [.remote])
  115. }
  116. }
  117. final class IndeedBrowseURLBuilderTests: XCTestCase {
  118. func testFakeJobKeyOpensStoredIndeedURL() {
  119. let listing = JobListing(
  120. indeedJobKey: "fixture-ios-001",
  121. title: "iOS Developer",
  122. company: "Acme",
  123. location: "Lahore, Pakistan",
  124. remoteType: .remote,
  125. postedAt: Date(),
  126. snippet: "Swift",
  127. indeedURL: URL(string: "https://pk.indeed.com/viewjob?jk=fixture-ios-001")!,
  128. availability: .open
  129. )
  130. let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
  131. XCTAssertFalse(resolved.usedBrowseFallback)
  132. XCTAssertEqual(resolved.url.host, "pk.indeed.com")
  133. XCTAssertTrue(resolved.url.absoluteString.contains("viewjob"))
  134. XCTAssertFalse(resolved.url.path.contains("/jobs"))
  135. }
  136. func testSearchQueryQuotesMultiWordCompany() {
  137. let q = IndeedBrowseURLBuilder.searchQuery(title: "iOS Developer", company: "Systems Limited")
  138. XCTAssertEqual(q, "iOS Developer company:\"Systems Limited\"")
  139. }
  140. func testRealHexJobKeyKeepsViewJob() {
  141. let listing = JobListing(
  142. indeedJobKey: "5185d0dd8c286093",
  143. title: "Senior Software Engineer",
  144. company: "Acme",
  145. location: "New York, NY",
  146. remoteType: .remote,
  147. postedAt: Date(),
  148. snippet: "Swift",
  149. indeedURL: URL(string: "https://www.indeed.com/viewjob?jk=5185d0dd8c286093")!,
  150. availability: .open
  151. )
  152. let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
  153. XCTAssertFalse(resolved.usedBrowseFallback)
  154. XCTAssertTrue(resolved.url.absoluteString.contains("viewjob"))
  155. }
  156. }
  157. final class DefaultJobSearchPipelineTests: XCTestCase {
  158. func testPipelineFiltersDedupesAndRanks() async throws {
  159. let duplicateOlder = makeListing(
  160. key: "dup",
  161. title: "iOS Developer",
  162. postedAt: Date().addingTimeInterval(-5_000)
  163. )
  164. let duplicateNewer = makeListing(
  165. key: "dup",
  166. title: "iOS Developer (Updated)",
  167. postedAt: Date()
  168. )
  169. let android = makeListing(key: "and", title: "Android Developer")
  170. let source = InlinePipelineDataSource(listings: [duplicateOlder, android, duplicateNewer])
  171. let pipeline = DefaultJobSearchPipeline(
  172. interpreter: StaticInterpreter(
  173. InterpretedSearch(
  174. naturalLanguageQuery: "iOS",
  175. criteria: {
  176. var c = SearchCriteria()
  177. c.jobTitle = "iOS"
  178. return c
  179. }(),
  180. confidenceNote: "test"
  181. )
  182. ),
  183. dataSource: source,
  184. rankingService: HeuristicJobRankingService(),
  185. validationService: CompositeJobValidationService(
  186. source: SourceSignalJobValidationService(),
  187. http: nil
  188. ),
  189. cache: NullJobListingCache()
  190. )
  191. let previousValidation = FeatureFlags.isListingValidationEnabled
  192. let previousRanking = FeatureFlags.isAIRankingEnabled
  193. FeatureFlags.isListingValidationEnabled = true
  194. FeatureFlags.isAIRankingEnabled = false
  195. defer {
  196. FeatureFlags.isListingValidationEnabled = previousValidation
  197. FeatureFlags.isAIRankingEnabled = previousRanking
  198. }
  199. let result = try await pipeline.search(
  200. JobSearchPipelineRequest(
  201. naturalLanguageQuery: "iOS jobs",
  202. userCriteria: SearchCriteria(),
  203. reinterpret: true
  204. )
  205. )
  206. XCTAssertEqual(result.listings.count, 1)
  207. XCTAssertEqual(result.listings.first?.indeedJobKey, "dup")
  208. XCTAssertEqual(result.listings.first?.title, "iOS Developer (Updated)")
  209. XCTAssertFalse(result.usedCache)
  210. }
  211. func testCacheHitSkipsRefetch() async throws {
  212. let listing = makeListing(key: "cached", title: "iOS Developer")
  213. let source = InlinePipelineDataSource(listings: [listing])
  214. let cache = InMemoryIndeedListingCache(ttl: 60)
  215. let pipeline = DefaultJobSearchPipeline(
  216. interpreter: StaticInterpreter(
  217. InterpretedSearch(
  218. naturalLanguageQuery: "iOS",
  219. criteria: {
  220. var c = SearchCriteria()
  221. c.keywords = "iOS"
  222. return c
  223. }(),
  224. confidenceNote: "test"
  225. )
  226. ),
  227. dataSource: source,
  228. rankingService: HeuristicJobRankingService(),
  229. validationService: CompositeJobValidationService(
  230. source: SourceSignalJobValidationService(),
  231. http: nil
  232. ),
  233. cache: cache
  234. )
  235. let request = JobSearchPipelineRequest(
  236. naturalLanguageQuery: "iOS",
  237. userCriteria: SearchCriteria(),
  238. reinterpret: true
  239. )
  240. _ = try await pipeline.search(request)
  241. let second = try await pipeline.search(request)
  242. XCTAssertTrue(second.usedCache)
  243. XCTAssertEqual(source.fetchCount, 1)
  244. }
  245. func testProgressiveYieldsPrimaryThenAppendsEnrichment() async throws {
  246. let primaryA = makeListing(key: "web1", title: "iOS Developer")
  247. let primaryB = makeListing(key: "web2", title: "iOS Engineer")
  248. let scrapeDup = makeListing(key: "web1", title: "iOS Developer Duplicate")
  249. let scrapeNew = makeListing(key: "live1", title: "Swift iOS Developer")
  250. let primary = InlinePipelineDataSource(listings: [primaryA, primaryB])
  251. let enrichment = InlinePipelineDataSource(listings: [scrapeDup, scrapeNew])
  252. let pipeline = DefaultJobSearchPipeline(
  253. interpreter: StaticInterpreter(
  254. InterpretedSearch(
  255. naturalLanguageQuery: "iOS",
  256. criteria: {
  257. var c = SearchCriteria()
  258. c.jobTitle = "iOS"
  259. return c
  260. }(),
  261. confidenceNote: "test"
  262. )
  263. ),
  264. dataSource: primary,
  265. enrichmentDataSource: enrichment,
  266. rankingService: HeuristicJobRankingService(),
  267. validationService: CompositeJobValidationService(
  268. source: SourceSignalJobValidationService(),
  269. http: nil
  270. ),
  271. cache: NullJobListingCache()
  272. )
  273. let previousValidation = FeatureFlags.isListingValidationEnabled
  274. let previousRanking = FeatureFlags.isAIRankingEnabled
  275. FeatureFlags.isListingValidationEnabled = true
  276. FeatureFlags.isAIRankingEnabled = false
  277. defer {
  278. FeatureFlags.isListingValidationEnabled = previousValidation
  279. FeatureFlags.isAIRankingEnabled = previousRanking
  280. }
  281. var phases: [JobSearchPipelineProgress.Phase] = []
  282. var lastKeys: [String] = []
  283. for try await progress in pipeline.searchProgressive(
  284. JobSearchPipelineRequest(
  285. naturalLanguageQuery: "iOS jobs",
  286. userCriteria: SearchCriteria(),
  287. reinterpret: true
  288. )
  289. ) {
  290. phases.append(progress.phase)
  291. lastKeys = progress.listings.map(\.indeedJobKey)
  292. if progress.phase == .partial {
  293. XCTAssertTrue(progress.isLoadingMore)
  294. XCTAssertEqual(Set(progress.listings.map(\.indeedJobKey)), Set(["web1", "web2"]))
  295. }
  296. }
  297. XCTAssertEqual(phases, [.partial, .complete])
  298. // Primary web results stay on top; scrape-only keys append after.
  299. XCTAssertEqual(Set(lastKeys.prefix(2)), Set(["web1", "web2"]))
  300. XCTAssertEqual(lastKeys.last, "live1")
  301. XCTAssertEqual(Set(lastKeys), Set(["web1", "web2", "live1"]))
  302. XCTAssertEqual(primary.fetchCount, 1)
  303. XCTAssertEqual(enrichment.fetchCount, 1)
  304. }
  305. }
  306. final class OpenAIModelsTests: XCTestCase {
  307. func testDefaultModelIsGPT6Luna() {
  308. XCTAssertEqual(OpenAIModels.defaultChatModel, "gpt-6-luna")
  309. let client = OpenAIClient(apiKey: "sk-test")
  310. XCTAssertEqual(client.model, "gpt-6-luna")
  311. XCTAssertEqual(client.reasoningEffort, "none")
  312. }
  313. }
  314. // MARK: - Helpers
  315. private func makeListing(
  316. key: String,
  317. title: String,
  318. snippet: String = "Build apps",
  319. postedAt: Date = Date()
  320. ) -> JobListing {
  321. JobListing(
  322. indeedJobKey: key,
  323. title: title,
  324. company: "Acme",
  325. location: "Lahore, Pakistan",
  326. remoteType: .remote,
  327. employmentType: .fullTime,
  328. experienceLevel: .mid,
  329. postedAt: postedAt,
  330. snippet: snippet,
  331. indeedURL: URL(string: "https://pk.indeed.com/viewjob?jk=\(key)")!,
  332. availability: .open
  333. )
  334. }
  335. private struct StaticInterpreter: OpenAISearchService {
  336. let value: InterpretedSearch
  337. init(_ value: InterpretedSearch) { self.value = value }
  338. func interpret(query: String) async throws -> InterpretedSearch { value }
  339. }
  340. private final class InlinePipelineDataSource: IndeedJobDataSource, @unchecked Sendable {
  341. let listings: [JobListing]
  342. private(set) var fetchCount = 0
  343. init(listings: [JobListing]) {
  344. self.listings = listings
  345. }
  346. func searchPage(
  347. criteria: SearchCriteria,
  348. query: String,
  349. page: IndeedSearchPageRequest
  350. ) async throws -> IndeedSearchPage {
  351. fetchCount += 1
  352. return IndeedSearchPage(
  353. listings: listings,
  354. pageIndex: page.pageIndex,
  355. pageSize: page.pageSize,
  356. totalEstimated: listings.count,
  357. nextCursor: nil,
  358. hasMore: false
  359. )
  360. }
  361. }