JobSearchPipelineTests.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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. }
  237. final class OpenAIModelsTests: XCTestCase {
  238. func testDefaultModelIsGPT6Luna() {
  239. XCTAssertEqual(OpenAIModels.defaultChatModel, "gpt-6-luna")
  240. let client = OpenAIClient(apiKey: "sk-test")
  241. XCTAssertEqual(client.model, "gpt-6-luna")
  242. XCTAssertEqual(client.reasoningEffort, "none")
  243. }
  244. }
  245. // MARK: - Helpers
  246. private func makeListing(
  247. key: String,
  248. title: String,
  249. snippet: String = "Build apps",
  250. postedAt: Date = Date()
  251. ) -> JobListing {
  252. JobListing(
  253. indeedJobKey: key,
  254. title: title,
  255. company: "Acme",
  256. location: "Lahore, Pakistan",
  257. remoteType: .remote,
  258. employmentType: .fullTime,
  259. experienceLevel: .mid,
  260. postedAt: postedAt,
  261. snippet: snippet,
  262. indeedURL: URL(string: "https://pk.indeed.com/viewjob?jk=\(key)")!,
  263. availability: .open
  264. )
  265. }
  266. private struct StaticInterpreter: OpenAISearchService {
  267. let value: InterpretedSearch
  268. init(_ value: InterpretedSearch) { self.value = value }
  269. func interpret(query: String) async throws -> InterpretedSearch { value }
  270. }
  271. private final class InlinePipelineDataSource: IndeedJobDataSource, @unchecked Sendable {
  272. let listings: [JobListing]
  273. private(set) var fetchCount = 0
  274. init(listings: [JobListing]) {
  275. self.listings = listings
  276. }
  277. func searchPage(
  278. criteria: SearchCriteria,
  279. query: String,
  280. page: IndeedSearchPageRequest
  281. ) async throws -> IndeedSearchPage {
  282. fetchCount += 1
  283. return IndeedSearchPage(
  284. listings: listings,
  285. pageIndex: page.pageIndex,
  286. pageSize: page.pageSize,
  287. totalEstimated: listings.count,
  288. nextCursor: nil,
  289. hasMore: false
  290. )
  291. }
  292. }