From 9e5cb62325e2ab78aa42b8284c145d87fe53bc69 Mon Sep 17 00:00:00 2001 From: riddim-developer-bot Date: Mon, 15 Jun 2026 09:00:37 -0400 Subject: [PATCH] [EPAC-2307]: Use numeric LEGISinfo id as Bill.id for backend bill-depth calls BillsService mapped Bill(id: number), so bill-detail backend calls (versions, diff, committee, amendments, lobbying, and PBO's /pbo/by-bill/{legisinfo_id}) used the display bill number (e.g. "C-11") as the path id. The backend bill-depth routes are keyed by the numeric LEGISinfo id (e.g. 13608745 for C-11), so those calls 404'd, the versions list came back nil, and the "Compare versions" action row stayed hidden. Decode the LEGISinfo BillId and use it as Bill.id, keeping Bill.number as the display value. Fall back to the number when LEGISinfo omits a usable id (leaving backend depth unavailable rather than mis-keyed). Follow/unfollow (BillFollowStore) and list/selection identity remain keyed to bill.number, so they are unaffected. Adds BillsServiceTests covering the numeric-id mapping and the fallback. --- ios/epac/Data/Adapters/BillsService.swift | 16 ++- ios/epac/Model/Bill.swift | 4 +- ios/epacTests/BillsServiceTests.swift | 162 ++++++++++++++++++++++ 3 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 ios/epacTests/BillsServiceTests.swift 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() {} +}