JobSearchPipelineTests.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  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. }
  120. func testRealHexJobKeyKeepsViewJob() {
  121. let listing = JobListing(
  122. indeedJobKey: "5185d0dd8c286093",
  123. title: "Senior Software Engineer",
  124. company: "Acme",
  125. location: "New York, NY",
  126. remoteType: .remote,
  127. postedAt: Date(),
  128. snippet: "Swift",
  129. indeedURL: URL(string: "https://www.indeed.com/viewjob?jk=5185d0dd8c286093")!,
  130. availability: .open
  131. )
  132. let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
  133. XCTAssertFalse(resolved.usedBrowseFallback)
  134. XCTAssertTrue(resolved.url.absoluteString.contains("viewjob"))
  135. }
  136. }
  137. final class DefaultJobSearchPipelineTests: XCTestCase {
  138. func testPipelineFiltersDedupesAndRanks() async throws {
  139. let duplicateOlder = makeListing(
  140. key: "dup",
  141. title: "iOS Developer",
  142. postedAt: Date().addingTimeInterval(-5_000)
  143. )
  144. let duplicateNewer = makeListing(
  145. key: "dup",
  146. title: "iOS Developer (Updated)",
  147. postedAt: Date()
  148. )
  149. let android = makeListing(key: "and", title: "Android Developer")
  150. let source = InlinePipelineDataSource(listings: [duplicateOlder, android, duplicateNewer])
  151. let pipeline = DefaultJobSearchPipeline(
  152. interpreter: StaticInterpreter(
  153. InterpretedSearch(
  154. naturalLanguageQuery: "iOS",
  155. criteria: {
  156. var c = SearchCriteria()
  157. c.jobTitle = "iOS"
  158. return c
  159. }(),
  160. confidenceNote: "test"
  161. )
  162. ),
  163. dataSource: source,
  164. rankingService: HeuristicJobRankingService(),
  165. validationService: CompositeJobValidationService(
  166. source: SourceSignalJobValidationService(),
  167. http: nil
  168. ),
  169. cache: NullJobListingCache()
  170. )
  171. let previousValidation = FeatureFlags.isListingValidationEnabled
  172. let previousRanking = FeatureFlags.isAIRankingEnabled
  173. FeatureFlags.isListingValidationEnabled = true
  174. FeatureFlags.isAIRankingEnabled = false
  175. defer {
  176. FeatureFlags.isListingValidationEnabled = previousValidation
  177. FeatureFlags.isAIRankingEnabled = previousRanking
  178. }
  179. let result = try await pipeline.search(
  180. JobSearchPipelineRequest(
  181. naturalLanguageQuery: "iOS jobs",
  182. userCriteria: SearchCriteria(),
  183. reinterpret: true
  184. )
  185. )
  186. XCTAssertEqual(result.listings.count, 1)
  187. XCTAssertEqual(result.listings.first?.indeedJobKey, "dup")
  188. XCTAssertEqual(result.listings.first?.title, "iOS Developer (Updated)")
  189. XCTAssertFalse(result.usedCache)
  190. }
  191. func testCacheHitSkipsRefetch() async throws {
  192. let listing = makeListing(key: "cached", title: "iOS Developer")
  193. let source = InlinePipelineDataSource(listings: [listing])
  194. let cache = InMemoryIndeedListingCache(ttl: 60)
  195. let pipeline = DefaultJobSearchPipeline(
  196. interpreter: StaticInterpreter(
  197. InterpretedSearch(
  198. naturalLanguageQuery: "iOS",
  199. criteria: {
  200. var c = SearchCriteria()
  201. c.keywords = "iOS"
  202. return c
  203. }(),
  204. confidenceNote: "test"
  205. )
  206. ),
  207. dataSource: source,
  208. rankingService: HeuristicJobRankingService(),
  209. validationService: CompositeJobValidationService(
  210. source: SourceSignalJobValidationService(),
  211. http: nil
  212. ),
  213. cache: cache
  214. )
  215. let request = JobSearchPipelineRequest(
  216. naturalLanguageQuery: "iOS",
  217. userCriteria: SearchCriteria(),
  218. reinterpret: true
  219. )
  220. _ = try await pipeline.search(request)
  221. let second = try await pipeline.search(request)
  222. XCTAssertTrue(second.usedCache)
  223. XCTAssertEqual(source.fetchCount, 1)
  224. }
  225. }
  226. final class OpenAIModelsTests: XCTestCase {
  227. func testDefaultModelIsGPT6Luna() {
  228. XCTAssertEqual(OpenAIModels.defaultChatModel, "gpt-6-luna")
  229. let client = OpenAIClient(apiKey: "sk-test")
  230. XCTAssertEqual(client.model, "gpt-6-luna")
  231. XCTAssertEqual(client.reasoningEffort, "none")
  232. }
  233. }
  234. // MARK: - Helpers
  235. private func makeListing(
  236. key: String,
  237. title: String,
  238. snippet: String = "Build apps",
  239. postedAt: Date = Date()
  240. ) -> JobListing {
  241. JobListing(
  242. indeedJobKey: key,
  243. title: title,
  244. company: "Acme",
  245. location: "Lahore, Pakistan",
  246. remoteType: .remote,
  247. employmentType: .fullTime,
  248. experienceLevel: .mid,
  249. postedAt: postedAt,
  250. snippet: snippet,
  251. indeedURL: URL(string: "https://pk.indeed.com/viewjob?jk=\(key)")!,
  252. availability: .open
  253. )
  254. }
  255. private struct StaticInterpreter: OpenAISearchService {
  256. let value: InterpretedSearch
  257. init(_ value: InterpretedSearch) { self.value = value }
  258. func interpret(query: String) async throws -> InterpretedSearch { value }
  259. }
  260. private final class InlinePipelineDataSource: IndeedJobDataSource, @unchecked Sendable {
  261. let listings: [JobListing]
  262. private(set) var fetchCount = 0
  263. init(listings: [JobListing]) {
  264. self.listings = listings
  265. }
  266. func searchPage(
  267. criteria: SearchCriteria,
  268. query: String,
  269. page: IndeedSearchPageRequest
  270. ) async throws -> IndeedSearchPage {
  271. fetchCount += 1
  272. return IndeedSearchPage(
  273. listings: listings,
  274. pageIndex: page.pageIndex,
  275. pageSize: page.pageSize,
  276. totalEstimated: listings.count,
  277. nextCursor: nil,
  278. hasMore: false
  279. )
  280. }
  281. }