diff --git a/ios/epac/Data/Adapters/BillsService.swift b/ios/epac/Data/Adapters/BillsService.swift index b8a35a11..900634a0 100644 --- a/ios/epac/Data/Adapters/BillsService.swift +++ b/ios/epac/Data/Adapters/BillsService.swift @@ -96,8 +96,21 @@ struct BillsService { guard let legisURL = URL(string: "\(legisInfoBase)/\(billSlug)") ?? URL(string: legisInfoBase) else { return nil } + // Backend bill-depth routes (versions, diff, committee, amendments, + // lobbying, PBO) are keyed by the numeric LEGISinfo id, not the display + // bill number. Carry that id in Bill.id so those calls resolve; keep + // Bill.number as the display value. Fall back to the number only when + // LEGISinfo omits a usable id, which leaves backend depth unavailable + // rather than mis-keyed. + let legisInfoID: String + if let billID = raw.BillId, billID > 0 { + legisInfoID = String(billID) + } else { + legisInfoID = number + } + return Bill( - id: number, + id: legisInfoID, number: number, title: title, sponsorName: raw.SponsorEn ?? "", @@ -236,6 +249,7 @@ private struct BillStageSpec { /// Mirrors the JSON object returned by the LEGISinfo bills endpoint. /// Only the fields we use are decoded; unknown fields are ignored. private struct LEGISinfoBill: Decodable { + let BillId: Int? let BillNumberFormatted: String let LongTitleEn: String? let ShortTitleEn: String? diff --git a/ios/epac/Model/Bill.swift b/ios/epac/Model/Bill.swift index 08632cf8..68232077 100644 --- a/ios/epac/Model/Bill.swift +++ b/ios/epac/Model/Bill.swift @@ -14,8 +14,8 @@ import SwiftUI // MARK: - Bill struct Bill: Identifiable, Codable, Sendable { - let id: String // bill number e.g. "C-50", "S-12" - let number: String // same as id + let id: String // numeric LEGISinfo id e.g. "13608745"; backend bill-depth key + let number: String // display bill number e.g. "C-50", "S-12" let title: String let sponsorName: String let status: BillStatus diff --git a/ios/epacTests/BillsServiceTests.swift b/ios/epacTests/BillsServiceTests.swift new file mode 100644 index 00000000..93b90ac8 --- /dev/null +++ b/ios/epacTests/BillsServiceTests.swift @@ -0,0 +1,162 @@ +@testable import epac +import Foundation +import Testing + +/// Mapping tests for `BillsService`, the LEGISinfo → `Bill` boundary. +/// +/// The bill-depth backend (versions, diff, committee, amendments, lobbying, +/// PBO) is keyed by the numeric LEGISinfo id, not the display bill number, so +/// `Bill.id` must carry that numeric id while `Bill.number` stays the display +/// value. Regression coverage for EPAC-2307. +@Suite(.serialized) +struct BillsServiceTests { + @Test func billMappingUsesNumericLegisInfoIdAsBillId() async throws { + let harness = try makeHarness() + defer { harness.cleanup() } + defer { BillsServiceMockURLProtocol.requestHandler = nil } + + BillsServiceMockURLProtocol.requestHandler = { request in + ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Self.legisInfoJSON() + ) + } + + let bills = try await BillsService.fetchBills(parliament: 45, session: 1, network: harness.service) + let bill = try #require(bills.first { $0.number == "C-11" }) + + // Backend bill-depth key — must be the numeric LEGISinfo id, not "C-11". + #expect(bill.id == "13608745") + #expect(bill.number == "C-11") + } + + @Test func billMappingFallsBackToNumberWhenLegisInfoIdMissingOrZero() async throws { + let harness = try makeHarness() + defer { harness.cleanup() } + defer { BillsServiceMockURLProtocol.requestHandler = nil } + + BillsServiceMockURLProtocol.requestHandler = { request in + ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Self.legisInfoJSON() + ) + } + + let bills = try await BillsService.fetchBills(parliament: 45, session: 1, network: harness.service) + + // BillId absent entirely → fall back to the display number. + let missing = try #require(bills.first { $0.number == "S-12" }) + #expect(missing.id == "S-12") + + // BillId present but not a usable id (<= 0) → fall back to the number. + let zero = try #require(bills.first { $0.number == "C-99" }) + #expect(zero.id == "C-99") + } + + // MARK: - Fixtures + + /// Minimal LEGISinfo JSON array exercising the three id cases the mapping + /// must handle: a numeric id, an absent id, and a non-usable (<= 0) id. + private static func legisInfoJSON() -> Data { + Data( + """ + [ + { + "BillId": 13608745, + "BillNumberFormatted": "C-11", + "ShortTitleEn": "An Act respecting example data", + "ParliamentNumber": 45, + "SessionNumber": 1 + }, + { + "BillNumberFormatted": "S-12", + "ShortTitleEn": "An Act respecting senate example data", + "ParliamentNumber": 45, + "SessionNumber": 1 + }, + { + "BillId": 0, + "BillNumberFormatted": "C-99", + "ShortTitleEn": "An Act respecting zero-id example data", + "ParliamentNumber": 45, + "SessionNumber": 1 + } + ] + """.utf8 + ) + } + + private func makeHarness() throws -> BillsServiceNetworkHarness { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [BillsServiceMockURLProtocol.self] + let session = URLSession(configuration: configuration) + + let suiteName = "BillsServiceTests.\(UUID().uuidString)" + let userDefaults = try #require(UserDefaults(suiteName: suiteName)) + userDefaults.removePersistentDomain(forName: suiteName) + + let cacheDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("BillsServiceTests-\(UUID().uuidString)", isDirectory: true) + let cacheStore = HTTPResponseCacheStore(userDefaults: userDefaults, cacheDirectory: cacheDirectory) + + return BillsServiceNetworkHarness( + service: NetworkService(session: session, cacheStore: cacheStore), + userDefaultsSuiteName: suiteName, + cacheDirectory: cacheDirectory + ) + } +} + +private struct BillsServiceNetworkHarness { + let service: NetworkService + let userDefaultsSuiteName: String + let cacheDirectory: URL + + func cleanup() { + UserDefaults.standard.removePersistentDomain(forName: userDefaultsSuiteName) + try? FileManager.default.removeItem(at: cacheDirectory) + } +} + +private enum BillsServiceMockURLProtocolError: Error { + case missingHandler +} + +private final class BillsServiceMockURLProtocol: URLProtocol { + nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + do { + guard let handler = Self.requestHandler else { + throw BillsServiceMockURLProtocolError.missingHandler + } + + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +}