IndeedListingMapperTests.swift 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. //
  2. // IndeedListingMapperTests.swift
  3. // indeed_ai_job_searchTests
  4. //
  5. import XCTest
  6. @testable import indeed_ai_job_search
  7. private let searchPage0JSON = """
  8. {
  9. "pageIndex": 0,
  10. "pageSize": 25,
  11. "totalEstimated": 3,
  12. "nextCursor": "page-1",
  13. "hasMore": true,
  14. "results": [
  15. {
  16. "jobKey": "fixture-ios-lahore-001",
  17. "title": "iOS Developer",
  18. "company": "Nexlify Studio",
  19. "location": "Lahore, Pakistan",
  20. "remoteType": "hybrid",
  21. "employmentType": "full-time",
  22. "experienceLevel": "mid",
  23. "salaryMin": 1800000,
  24. "salaryMax": 2800000,
  25. "salaryCurrency": "PKR",
  26. "salaryPeriod": "year",
  27. "postedAt": "2026-10-08T09:30:00Z",
  28. "snippet": "Build SwiftUI features for a consumer productivity app.",
  29. "url": "https://pk.indeed.com/jobs?q=iOS+Developer&l=Lahore%2C+Pakistan",
  30. "availability": "open",
  31. "isEasyApply": true
  32. },
  33. {
  34. "jobKey": "fixture-swift-remote-002",
  35. "title": "Senior Swift Engineer",
  36. "company": "Alpine Cloud",
  37. "location": "Remote, Pakistan",
  38. "remoteType": "remote",
  39. "employmentType": "full-time",
  40. "experienceLevel": "senior",
  41. "salaryMin": 90000,
  42. "salaryMax": 130000,
  43. "salaryCurrency": "USD",
  44. "salaryPeriod": "year",
  45. "postedAt": "2026-10-10T14:00:00Z",
  46. "snippet": "Own macOS and iOS modules.",
  47. "url": "https://www.indeed.com/jobs?q=Senior+Swift+Engineer&l=Remote",
  48. "availability": "open"
  49. },
  50. {
  51. "title": "Android Developer",
  52. "company": "Karachi Labs",
  53. "location": "Karachi, Pakistan",
  54. "remoteType": "onsite",
  55. "url": "https://pk.indeed.com/jobs?q=Android+Developer&l=Karachi%2C+Pakistan",
  56. "availability": "unknown",
  57. "snippet": "Kotlin multiplatform experiments."
  58. }
  59. ]
  60. }
  61. """
  62. final class IndeedListingMapperTests: XCTestCase {
  63. func testMapsFixturePageToJobListings() throws {
  64. let page = try IndeedListingMapper.decodeFixture(from: Data(searchPage0JSON.utf8))
  65. XCTAssertEqual(page.listings.count, 3)
  66. XCTAssertEqual(page.pageIndex, 0)
  67. XCTAssertTrue(page.hasMore)
  68. XCTAssertEqual(page.nextCursor, "page-1")
  69. let first = try XCTUnwrap(page.listings.first)
  70. XCTAssertEqual(first.indeedJobKey, "fixture-ios-lahore-001")
  71. XCTAssertEqual(first.title, "iOS Developer")
  72. XCTAssertEqual(first.remoteType, .hybrid)
  73. XCTAssertEqual(first.employmentType, .fullTime)
  74. XCTAssertEqual(first.experienceLevel, .mid)
  75. XCTAssertEqual(first.salary?.currency, "PKR")
  76. XCTAssertEqual(first.availability, .open)
  77. XCTAssertTrue(IndeedURLCanonicalizer.isIndeedHost(first.indeedURL.host ?? ""))
  78. }
  79. func testMapsBrowseURLWhenProviderKeyMissing() throws {
  80. let page = try IndeedListingMapper.decodeFixture(from: Data(searchPage0JSON.utf8))
  81. let android = try XCTUnwrap(page.listings.first { $0.title.contains("Android") })
  82. // No provider key + browse URL → stable hash key, openable /jobs URL.
  83. XCTAssertTrue(android.indeedJobKey.hasPrefix("url-"))
  84. XCTAssertTrue(android.indeedURL.path.contains("/jobs"))
  85. XCTAssertTrue(IndeedURLCanonicalizer.isIndeedHost(android.indeedURL.host ?? ""))
  86. let q = URLComponents(url: android.indeedURL, resolvingAgainstBaseURL: false)?
  87. .queryItems?
  88. .first { $0.name == "q" }?
  89. .value ?? ""
  90. XCTAssertTrue(q.contains("company:\"Karachi Labs\"") || q.contains("company:Karachi"), q)
  91. XCTAssertNil(android.salary, "Salary omitted when currency/amounts are not provided")
  92. }
  93. func testOmitsSalaryWhenCurrencyMissing() throws {
  94. let dto = IndeedJobDTO(
  95. jobKey: "fixture-no-currency",
  96. title: "iOS Developer",
  97. company: "Acme",
  98. location: "Lahore, Pakistan",
  99. salaryMin: 100,
  100. salaryMax: 200,
  101. salaryCurrency: nil,
  102. url: "https://pk.indeed.com/jobs?q=iOS"
  103. )
  104. let listing = try IndeedListingMapper.map(dto)
  105. XCTAssertNil(listing.salary)
  106. }
  107. func testRejectsNonIndeedURL() {
  108. let dto = IndeedJobDTO(
  109. jobKey: "x",
  110. title: "Role",
  111. company: "Co",
  112. location: "Remote",
  113. url: "https://example.com/jobs/1"
  114. )
  115. XCTAssertThrowsError(try IndeedListingMapper.map(dto)) { error in
  116. guard case IndeedDataError.nonIndeedURL = error else {
  117. return XCTFail("Expected nonIndeedURL, got \(error)")
  118. }
  119. }
  120. }
  121. }
  122. final class IndeedURLCanonicalizerTests: XCTestCase {
  123. func testAcceptsRegionalHosts() throws {
  124. let url = try IndeedURLCanonicalizer.canonicalize("https://pk.indeed.com/viewjob?jk=abc")
  125. XCTAssertEqual(url.host, "pk.indeed.com")
  126. XCTAssertEqual(IndeedURLCanonicalizer.extractJobKey(from: url), "abc")
  127. }
  128. func testAcceptsIndeedCoUK() throws {
  129. let url = try IndeedURLCanonicalizer.canonicalize("http://indeed.co.uk/viewjob?jk=uk1")
  130. XCTAssertEqual(url.scheme, "https")
  131. XCTAssertEqual(url.host, "indeed.co.uk")
  132. }
  133. func testRejectsForeignJobBoard() {
  134. XCTAssertThrowsError(try IndeedURLCanonicalizer.canonicalize("https://linkedin.com/jobs/1"))
  135. }
  136. }
  137. final class FixtureIndeedJobDataSourceTests: XCTestCase {
  138. func testSearchPageContractFiltersByQuery() async throws {
  139. let page = try IndeedListingMapper.decodeFixture(from: Data(searchPage0JSON.utf8))
  140. let source = InlineFixtureIndeedJobDataSource(page: page)
  141. let results = try await source.search(criteria: SearchCriteria(), query: "iOS")
  142. XCTAssertTrue(results.contains { $0.title.localizedCaseInsensitiveContains("iOS") })
  143. }
  144. }
  145. /// Test double that satisfies `IndeedJobDataSource` without depending on app bundle copy phases.
  146. private struct InlineFixtureIndeedJobDataSource: IndeedJobDataSource {
  147. let page: IndeedSearchPage
  148. func searchPage(
  149. criteria: SearchCriteria,
  150. query: String,
  151. page: IndeedSearchPageRequest
  152. ) async throws -> IndeedSearchPage {
  153. var filtered = self.page
  154. let needle = query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
  155. if !needle.isEmpty {
  156. filtered.listings = filtered.listings.filter {
  157. "\($0.title) \($0.company) \($0.snippet)".lowercased().contains(needle)
  158. }
  159. }
  160. return filtered
  161. }
  162. }