JobSearchPipelineTests.swift 14 KB

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