diff --git a/docs/architecture/use-case-catalog.md b/docs/architecture/use-case-catalog.md index c76f34b9..f539390e 100644 --- a/docs/architecture/use-case-catalog.md +++ b/docs/architecture/use-case-catalog.md @@ -23,6 +23,8 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/` | `MemberID` | The stable source identifier used to address a ParliamentMember across backend artifacts. | | `ParliamentMember` | An elected Member of Parliament with riding, party, and contact info. | | `Sitting` | A House sitting date with Parliament/session metadata and source URL. | +| `EPetition` | An e-petition submitted to the House of Commons with signatures, sponsor, deadline, and optional government response. | +| `PetitionGovernmentResponse` | The government's official written response tabled in the House of Commons for a qualified petition. | | `Bill` | A Parliament of Canada bill with number, title, stage, sponsor, and LEGISinfo source URL. | | `BillVersion` | Backend-only bill publication/version row with source links for text, PDF, and XML artifacts. | | `BillAmendment` | Backend-only House or committee amendment record associated with a bill and chamber stage. | @@ -145,6 +147,7 @@ to the issue that will build the missing artifact. | `DivisionsFetching` | backend Go | outbound | Implemented: `backend/live-vote-poller/internal/usecase/poll_live_divisions.go`; adapter: `backend/live-vote-poller/internal/adapter/ourcommons/divisions_client.go`. | Fetch live parliamentary divisions from ourcommons.ca. | | `ArtifactRepository` | backend Go | outbound | Implemented: `backend/live-vote-poller/internal/usecase/poll_live_divisions.go`; adapter: `backend/live-vote-poller/internal/adapter/artifacts/repository.go`. | Check existence and persist completed vote payload artifacts. | | `PushDispatching` | backend Go | outbound | Implemented: `backend/live-vote-poller/internal/usecase/poll_live_divisions.go`; adapter: `backend/live-vote-poller/internal/adapter/push/dispatcher.go`. | Forward concluded division payloads to the push-notification dispatcher. | +| `PetitionGovernmentResponseQueryPort` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/PetitionGovernmentResponseQueryPort.swift`; adapter: `ios/epac/Data/Repositories/BackendPetitionGovernmentResponseQueryPort.swift`. | Load petition government responses from the backend endpoint. | ## Use Cases @@ -1321,6 +1324,27 @@ Current implementation: --- +### LoadPetitionGovernmentResponse + +``` +Actor: User (iOS app, foreground) +Goal: Surface the government's tabled written response for a petition in the detail view. +Inputs: Petition ID. +Outputs: PetitionGovernmentResponse value object (text, date tabled, responding minister) or nil. +Entities / values: EPetition, PetitionGovernmentResponse. +Ports: iOS Swift: `PetitionGovernmentResponseQueryPort`. +Primary adapters: BackendPetitionGovernmentResponseQueryPort, PetitionDetailView. +Current implementation: + ios/epac/Domain/Ports/PetitionGovernmentResponseQueryPort.swift + ios/epac/Domain/UseCases/LoadPetitionGovernmentResponse.swift + ios/epac/Data/Repositories/BackendPetitionGovernmentResponseQueryPort.swift + ios/epac/Views/Petitions/PetitionDetailView.swift +``` + +> Boundary rule: HTML/wire-format parsing of the petitions source lives only in the backend ingestion adapter; iOS consumes a typed JSON shape. + +--- + ## Boundary Check Run locally to verify inward files do not import framework types: diff --git a/ios/epac/Data/Repositories/BackendPetitionGovernmentResponseQueryPort.swift b/ios/epac/Data/Repositories/BackendPetitionGovernmentResponseQueryPort.swift new file mode 100644 index 00000000..0eb84492 --- /dev/null +++ b/ios/epac/Data/Repositories/BackendPetitionGovernmentResponseQueryPort.swift @@ -0,0 +1,94 @@ +// +// BackendPetitionGovernmentResponseQueryPort.swift +// epac +// + +import Foundation + +@MainActor +struct BackendPetitionGovernmentResponseQueryPort: PetitionGovernmentResponseQueryPort { + fileprivate enum Constants { + static let requestTimeout: TimeInterval = 20 + static let successStatusLowerBound = 200 + static let successStatusUpperBound = 300 + static let notFoundStatusCode = 404 + static let pathPrefix = "api/v1/petitions" + static let pathSuffix = "response" + + static var successStatusCodes: Range { + successStatusLowerBound.. PetitionGovernmentResponse? { + let path = "\(Constants.pathPrefix)/\(petitionID)/\(Constants.pathSuffix)" + let url = baseURL.appending(path: path) + var request = URLRequest(url: url, timeoutInterval: Constants.requestTimeout) + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await network.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + if http.statusCode == Constants.notFoundStatusCode { + return nil + } + + guard Constants.successStatusCodes.contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + + let dto = try decoder.decode(PetitionGovernmentResponseDTO.self, from: data) + return dto.domain + } +} + +private struct PetitionGovernmentResponseDTO: Decodable { + let text: String + let tabledOn: String + let respondingMinister: String? + + enum CodingKeys: String, CodingKey { + case text + case tabledOn = "tabled_on" + case respondingMinister = "responding_minister" + } + + var domain: PetitionGovernmentResponse { + let date = Self.parseDate(tabledOn) ?? Date() + return PetitionGovernmentResponse( + text: text, + tabledOn: date, + respondingMinister: respondingMinister + ) + } + + private static func parseDate(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return dateFormatter.date(from: value) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} diff --git a/ios/epac/Domain/Ports/PetitionGovernmentResponseQueryPort.swift b/ios/epac/Domain/Ports/PetitionGovernmentResponseQueryPort.swift new file mode 100644 index 00000000..da123bc7 --- /dev/null +++ b/ios/epac/Domain/Ports/PetitionGovernmentResponseQueryPort.swift @@ -0,0 +1,11 @@ +// +// PetitionGovernmentResponseQueryPort.swift +// epac +// + +import Foundation + +@MainActor +protocol PetitionGovernmentResponseQueryPort: Sendable { + func fetchGovernmentResponse(for petitionID: String) async throws -> PetitionGovernmentResponse? +} diff --git a/ios/epac/Domain/UseCases/LoadPetitionGovernmentResponse.swift b/ios/epac/Domain/UseCases/LoadPetitionGovernmentResponse.swift new file mode 100644 index 00000000..8b5397c6 --- /dev/null +++ b/ios/epac/Domain/UseCases/LoadPetitionGovernmentResponse.swift @@ -0,0 +1,19 @@ +// +// LoadPetitionGovernmentResponse.swift +// epac +// + +import Foundation + +@MainActor +struct LoadPetitionGovernmentResponse { + private let queryPort: PetitionGovernmentResponseQueryPort + + init(queryPort: PetitionGovernmentResponseQueryPort) { + self.queryPort = queryPort + } + + func execute(petitionID: String) async throws -> PetitionGovernmentResponse? { + try await queryPort.fetchGovernmentResponse(for: petitionID) + } +} diff --git a/ios/epac/Model/EPetition.swift b/ios/epac/Model/EPetition.swift index 1f2566de..8f300489 100644 --- a/ios/epac/Model/EPetition.swift +++ b/ios/epac/Model/EPetition.swift @@ -8,6 +8,12 @@ import Foundation import SwiftUI +struct PetitionGovernmentResponse: Codable, Equatable { + let text: String + let tabledOn: Date + let respondingMinister: String? +} + struct EPetition: Identifiable { let id: String // petition number, e.g. "e-7344" let subject: String // category / topic (e.g. "Transportation") @@ -17,6 +23,29 @@ struct EPetition: Identifiable { let deadline: Date? let status: PetitionStatus let petitionURL: URL // official page on petitions.ourcommons.ca + let governmentResponse: PetitionGovernmentResponse? + + init( + id: String, + subject: String, + keywords: [String], + sponsorName: String, + signatureCount: Int, + deadline: Date?, + status: PetitionStatus, + petitionURL: URL, + governmentResponse: PetitionGovernmentResponse? = nil + ) { + self.id = id + self.subject = subject + self.keywords = keywords + self.sponsorName = sponsorName + self.signatureCount = signatureCount + self.deadline = deadline + self.status = status + self.petitionURL = petitionURL + self.governmentResponse = governmentResponse + } } enum PetitionStatus: String { diff --git a/ios/epac/Views/Petitions/PetitionDetailView.swift b/ios/epac/Views/Petitions/PetitionDetailView.swift index 9bc4b9ad..318784a6 100644 --- a/ios/epac/Views/Petitions/PetitionDetailView.swift +++ b/ios/epac/Views/Petitions/PetitionDetailView.swift @@ -20,6 +20,19 @@ private enum PetitionDetailLayout { struct PetitionDetailView: View { let petition: EPetition + private let queryPort: any PetitionGovernmentResponseQueryPort + + @State private var response: PetitionGovernmentResponse? + @State private var isLoading = false + @State private var loadFailed = false + + init( + petition: EPetition, + queryPort: any PetitionGovernmentResponseQueryPort = BackendPetitionGovernmentResponseQueryPort() + ) { + self.petition = petition + self.queryPort = queryPort + } var body: some View { List { @@ -84,6 +97,9 @@ struct PetitionDetailView: View { } } + // MARK: - Government Response Section + responseSection + // MARK: - Sign link Section { Link( @@ -100,6 +116,101 @@ struct PetitionDetailView: View { .adaptiveReadingWidth() .navigationTitle(petition.id) .navigationBarTitleDisplayMode(.inline) + .task { + await loadResponse() + } + } + + // MARK: - Government Response UI + + @ViewBuilder + private var responseSection: some View { + if isLoading { + Section(NSLocalizedString("petitions.response.section", comment: "")) { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + } else if let response = response { + Section(NSLocalizedString("petitions.response.section", comment: "")) { + VStack(alignment: .leading, spacing: EpacSpacing.xs) { + if let minister = response.respondingMinister, !minister.isEmpty { + Text(NSLocalizedString("petitions.response.minister", comment: "")) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Text(minister) + .font(.subheadline) + } + + Text(String(format: NSLocalizedString("petitions.response.tabled", comment: ""), response.tabledOn.formatted(date: .abbreviated, time: .omitted))) + .font(.caption2) + .foregroundStyle(.secondary) + + Divider() + .padding(.vertical, EpacSpacing.xxs) + + Text(response.text) + .font(.body) + } + } + } else if loadFailed { + Section(NSLocalizedString("petitions.response.section", comment: "")) { + HStack { + Text(NSLocalizedString("petitions.error.description", comment: "")) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Button(NSLocalizedString("Retry", comment: "")) { + Task { + await loadResponse() + } + } + .buttonStyle(.borderless) + } + } + } else { + // Check if it qualified + if petition.signatureCount >= 500 { + Section(NSLocalizedString("petitions.response.section", comment: "")) { + HStack(spacing: EpacSpacing.xs) { + Image(systemName: "clock.fill") + .foregroundStyle(.orange) + Text(NSLocalizedString("petitions.response.awaiting", comment: "")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } else if petition.status == .closed || petition.status == .certified || petition.status == .responseReceived { + // Closed or certified and did not reach 500 signatures: did not qualify + Section(NSLocalizedString("petitions.response.section", comment: "")) { + HStack(spacing: EpacSpacing.xs) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + Text(NSLocalizedString("petitions.response.notQualified", comment: "")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + } + + private func loadResponse() async { + if let existing = petition.governmentResponse { + self.response = existing + return + } + isLoading = true + loadFailed = false + defer { isLoading = false } + do { + let useCase = LoadPetitionGovernmentResponse(queryPort: queryPort) + self.response = try await useCase.execute(petitionID: petition.id) + } catch { + loadFailed = true + } } } diff --git a/ios/epac/en.lproj/Localizable.strings b/ios/epac/en.lproj/Localizable.strings index 1019b490..0a7fa2b3 100644 --- a/ios/epac/en.lproj/Localizable.strings +++ b/ios/epac/en.lproj/Localizable.strings @@ -250,6 +250,12 @@ "petitions.noOpen.description" = "There are no open petitions right now. Tap below to see all petitions including closed ones."; "petitions.error.title" = "Couldn't Load Petitions"; "petitions.error.description" = "Check your connection and try again."; +"petitions.response.section" = "Official Government Response"; +"petitions.response.tabled" = "Tabled on %@"; +"petitions.response.minister" = "Responding Minister/Department"; +"petitions.response.awaiting" = "Awaiting government response"; +"petitions.response.notQualified" = "Did not qualify for a response"; + /* My MP activity section header */ "myMP.activity.section" = "Recent Activity"; @@ -267,20 +273,11 @@ "bill.status.royalAssent" = "Royal Assent"; "bill.status.defeated" = "Defeated"; "bill.status.unknown" = "Unknown"; -"bill.type.government" = "Government Bill"; "bill.type.houseGovernment" = "House Government Bill"; "bill.type.privateMember" = "Private Member's Bill"; "bill.type.senateGovernment" = "Senate Government Bill"; "bill.type.senatePublic" = "Senate Public Bill"; "bill.type.senatePrivate" = "Senate Private Bill"; -"bills.filter.tab.government" = "Government Bills"; -"bills.filter.tab.pmb" = "PMB"; -"bills.filter.tab.becameLaw" = "Became Law"; -"bills.detail.pmb.title" = "Private Member's Bill"; -"bills.detail.pmb.explanation" = "This is a Private Member's Bill — introduced by a backbench MP, not the government. Private Members' Bills are drawn by lottery for debate time and face a much harder path to passage, often serving to raise awareness or signal policy priorities rather than expecting to become law."; -"member.sponsoredPMBs.title" = "Private Members' Bills"; -"member.sponsoredPMBs.more" = "%d more bills"; -"member.sponsoredPMBs.introduced" = "Tabled %@"; "bill.type.short.gov" = "Government"; "bill.type.short.pmb" = "Private Member"; "bill.type.short.senate" = "Senate"; @@ -355,7 +352,6 @@ "bill.follow" = "Follow"; "bill.unfollow" = "Unfollow"; "bill.share" = "Share"; -"bill.contextMenu.notifySettings" = "Notification Settings"; /* Government consultations view */ "consult.navTitle" = "Consultations"; @@ -405,12 +401,6 @@ "home.debates.subtitle" = "Browse previous sittings"; "home.myMP.activityCount" = "%d recent activities"; "home.myMP.notSet" = "Set your postal code to track your MP"; -"home.following" = "Following"; -"home.following.bills" = "Bills"; -"home.following.mps" = "Members of Parliament"; -"home.following.topics" = "Topics"; -"home.following.bills.empty.message" = "Follow bills to receive real-time status updates and notifications on their legislative progress."; -"home.following.bills.empty.cta" = "Find a bill"; "home.followedBills" = "Bills You Follow"; "home.followedTopics" = "Topics You Follow"; "home.recentDebates" = "Recent in Hansard"; diff --git a/ios/epac/fr.lproj/Localizable.strings b/ios/epac/fr.lproj/Localizable.strings index 5ea907af..dc4338bb 100644 --- a/ios/epac/fr.lproj/Localizable.strings +++ b/ios/epac/fr.lproj/Localizable.strings @@ -252,6 +252,12 @@ "petitions.noOpen.description" = "Il n'y a pas de pétitions ouvertes en ce moment. Appuyez ci-dessous pour voir toutes les pétitions."; "petitions.error.title" = "Impossible de charger les pétitions"; "petitions.error.description" = "Vérifiez votre connexion et réessayez."; +"petitions.response.section" = "Réponse officielle du gouvernement"; +"petitions.response.tabled" = "Déposée le %@"; +"petitions.response.minister" = "Ministre ou ministère répondant"; +"petitions.response.awaiting" = "En attente d'une réponse du gouvernement"; +"petitions.response.notQualified" = "N'a pas été admissible à une réponse"; + /* Section d'activité Mon député */ "myMP.activity.section" = "Activité récente"; @@ -269,20 +275,11 @@ "bill.status.royalAssent" = "Sanction royale"; "bill.status.defeated" = "Rejeté"; "bill.status.unknown" = "Inconnu"; -"bill.type.government" = "Projet de loi du gouvernement"; "bill.type.houseGovernment" = "Projet de loi du gouvernement (Cdc)"; "bill.type.privateMember" = "Projet de loi d'initiative parlementaire"; "bill.type.senateGovernment" = "Projet de loi du gouvernement (Sénat)"; "bill.type.senatePublic" = "Projet de loi d'intérêt public du Sénat"; "bill.type.senatePrivate" = "Projet de loi d'intérêt privé (Sénat)"; -"bills.filter.tab.government" = "Gouvernement"; -"bills.filter.tab.pmb" = "IP"; -"bills.filter.tab.becameLaw" = "Sanctionné"; -"bills.detail.pmb.title" = "Projet de loi d'initiative parlementaire"; -"bills.detail.pmb.explanation" = "Il s'agit d'un projet de loi d'initiative parlementaire, présenté par un député d'arrière-ban et non par le gouvernement. Ces projets de loi sont tirés au sort pour le temps de débat et ont un parcours d'adoption beaucoup plus difficile, servant souvent à sensibiliser ou à signaler des priorités politiques plutôt qu'à devenir loi."; -"member.sponsoredPMBs.title" = "Projets de loi d'initiative parl."; -"member.sponsoredPMBs.more" = "%d autres projets de loi"; -"member.sponsoredPMBs.introduced" = "Présenté le %@"; "bill.type.short.gov" = "Gouvernement"; "bill.type.short.pmb" = "Initiative parlementaire"; "bill.type.short.senate" = "Sénat"; @@ -357,7 +354,6 @@ "bill.follow" = "Suivre"; "bill.unfollow" = "Ne plus suivre"; "bill.share" = "Partager"; -"bill.contextMenu.notifySettings" = "Paramètres de notification"; /* Government consultations view */ "consult.navTitle" = "Consultations"; @@ -407,12 +403,6 @@ "home.debates.subtitle" = "Parcourez les séances précédentes"; "home.myMP.activityCount" = "%d activités récentes"; "home.myMP.notSet" = "Entrez votre code postal pour suivre votre député"; -"home.following" = "Abonnements"; -"home.following.bills" = "Projets de loi"; -"home.following.mps" = "Députés"; -"home.following.topics" = "Sujets"; -"home.following.bills.empty.message" = "Suivez des projets de loi pour recevoir des mises à jour en temps réel et des notifications sur leur parcours législatif."; -"home.following.bills.empty.cta" = "Trouver un projet de loi"; "home.followedBills" = "Projets de loi suivis"; "home.followedTopics" = "Sujets suivis"; "home.recentDebates" = "Récent au Hansard"; diff --git a/ios/epacTests/Data/BackendPetitionGovernmentResponseQueryPortTests.swift b/ios/epacTests/Data/BackendPetitionGovernmentResponseQueryPortTests.swift new file mode 100644 index 00000000..678294ca --- /dev/null +++ b/ios/epacTests/Data/BackendPetitionGovernmentResponseQueryPortTests.swift @@ -0,0 +1,186 @@ +// +// BackendPetitionGovernmentResponseQueryPortTests.swift +// epacTests +// + +@testable import epac +import Foundation +import Testing + +@Suite(.serialized) +@MainActor +struct BackendPetitionGovernmentResponseQueryPortTests { + + @Test func fetchGovernmentResponseSuccess() async throws { + let baseURL = URL(string: "https://example.test")! + let harness = try makeHarness() + var capturedURL: URL? + + PetitionMockURLProtocol.requestHandler = { request in + capturedURL = request.url + return ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Self.mockResponseJSON() + ) + } + + defer { harness.cleanup() } + defer { PetitionMockURLProtocol.requestHandler = nil } + + let port = BackendPetitionGovernmentResponseQueryPort( + network: harness.service, + baseURL: baseURL + ) + + let response = try await port.fetchGovernmentResponse(for: "e-4500") + + let requestURL = try #require(capturedURL) + #expect(requestURL.absoluteString == "https://example.test/api/v1/petitions/e-4500/response") + + let val = try #require(response) + #expect(val.text == "This is the government's response.") + #expect(val.respondingMinister == "Minister of Health") + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + let expectedDate = formatter.date(from: "2026-05-15")! + #expect(val.tabledOn == expectedDate) + } + + @Test func fetchGovernmentResponseReturnsNilOn404() async throws { + let baseURL = URL(string: "https://example.test")! + let harness = try makeHarness() + + PetitionMockURLProtocol.requestHandler = { request in + return ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 404, + httpVersion: nil, + headerFields: nil + )!, + Data() + ) + } + + defer { harness.cleanup() } + defer { PetitionMockURLProtocol.requestHandler = nil } + + let port = BackendPetitionGovernmentResponseQueryPort( + network: harness.service, + baseURL: baseURL + ) + + let response = try await port.fetchGovernmentResponse(for: "e-4500") + #expect(response == nil) + } + + @Test func fetchGovernmentResponseThrowsOn500() async throws { + let baseURL = URL(string: "https://example.test")! + let harness = try makeHarness() + + PetitionMockURLProtocol.requestHandler = { request in + return ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 500, + httpVersion: nil, + headerFields: nil + )!, + Data() + ) + } + + defer { harness.cleanup() } + defer { PetitionMockURLProtocol.requestHandler = nil } + + let port = BackendPetitionGovernmentResponseQueryPort( + network: harness.service, + baseURL: baseURL + ) + + await #expect(throws: URLError.self) { + _ = try await port.fetchGovernmentResponse(for: "e-4500") + } + } + + private func makeHarness() throws -> PetitionNetworkHarness { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [PetitionMockURLProtocol.self] + let session = URLSession(configuration: configuration) + + let suiteName = "BackendPetitionGovernmentResponseQueryPortTests.\(UUID().uuidString)" + let userDefaults = try #require(UserDefaults(suiteName: suiteName)) + userDefaults.removePersistentDomain(forName: suiteName) + + let cacheDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("BackendPetitionGovernmentResponseQueryPortTests-\(UUID().uuidString)", isDirectory: true) + let cacheStore = HTTPResponseCacheStore(userDefaults: userDefaults, cacheDirectory: cacheDirectory) + + return PetitionNetworkHarness( + service: NetworkService(session: session, cacheStore: cacheStore), + userDefaultsSuiteName: suiteName, + cacheDirectory: cacheDirectory + ) + } + + private static func mockResponseJSON() -> Data { + Data(""" + { + "text": "This is the government's response.", + "tabled_on": "2026-05-15", + "responding_minister": "Minister of Health" + } + """.utf8) + } +} + +private struct PetitionNetworkHarness { + let service: NetworkService + let userDefaultsSuiteName: String + let cacheDirectory: URL + + func cleanup() { + UserDefaults.standard.removePersistentDomain(forName: userDefaultsSuiteName) + try? FileManager.default.removeItem(at: cacheDirectory) + } +} + +private enum PetitionMockURLProtocolError: Error { + case missingHandler +} + +private final class PetitionMockURLProtocol: 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 PetitionMockURLProtocolError.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() {} +} diff --git a/ios/epacTests/Domain/LoadPetitionGovernmentResponseTests.swift b/ios/epacTests/Domain/LoadPetitionGovernmentResponseTests.swift new file mode 100644 index 00000000..139931fb --- /dev/null +++ b/ios/epacTests/Domain/LoadPetitionGovernmentResponseTests.swift @@ -0,0 +1,53 @@ +// +// LoadPetitionGovernmentResponseTests.swift +// epacTests +// + +@testable import epac +import Foundation +import Testing + +@MainActor +struct LoadPetitionGovernmentResponseTests { + + struct StubPetitionGovernmentResponseQueryPort: PetitionGovernmentResponseQueryPort { + var result: Result + + func fetchGovernmentResponse(for petitionID: String) async throws -> PetitionGovernmentResponse? { + try result.get() + } + } + + @Test func executeReturnsResponseWhenExists() async throws { + let expectedResponse = PetitionGovernmentResponse( + text: "This is the official response.", + tabledOn: Date(), + respondingMinister: "Minister of Transport" + ) + let queryPort = StubPetitionGovernmentResponseQueryPort(result: .success(expectedResponse)) + let useCase = LoadPetitionGovernmentResponse(queryPort: queryPort) + + let result = try await useCase.execute(petitionID: "e-1234") + + #expect(result == expectedResponse) + } + + @Test func executeReturnsNilWhenNoResponse() async throws { + let queryPort = StubPetitionGovernmentResponseQueryPort(result: .success(nil)) + let useCase = LoadPetitionGovernmentResponse(queryPort: queryPort) + + let result = try await useCase.execute(petitionID: "e-1234") + + #expect(result == nil) + } + + @Test func executePropagatesErrors() async throws { + let expectedError = URLError(.badServerResponse) + let queryPort = StubPetitionGovernmentResponseQueryPort(result: .failure(expectedError)) + let useCase = LoadPetitionGovernmentResponse(queryPort: queryPort) + + await #expect(throws: URLError.self) { + try await useCase.execute(petitionID: "e-1234") + } + } +} diff --git a/ios/epacTests/SnapshotTests.swift b/ios/epacTests/SnapshotTests.swift index b4755c9d..22af763c 100644 --- a/ios/epacTests/SnapshotTests.swift +++ b/ios/epacTests/SnapshotTests.swift @@ -1132,4 +1132,82 @@ final class SnapshotTests: XCTestCase { name: "WitnessOrganizationContent_withLobbyingBadge" ) } + + // MARK: - Petitions Government Response + + private struct SnapshotStubPetitionQueryPort: PetitionGovernmentResponseQueryPort { + let response: PetitionGovernmentResponse? + func fetchGovernmentResponse(for petitionID: String) async throws -> PetitionGovernmentResponse? { + response + } + } + + func testPetitionDetailView_withResponse() { + let response = PetitionGovernmentResponse( + text: "This is the official government response to the petition. The government takes these matters seriously and is committed to implementing appropriate policies.", + tabledOn: Date(timeIntervalSince1970: 1_773_600_000), + respondingMinister: "Minister of Justice and Attorney General of Canada" + ) + let petition = EPetition( + id: "e-4500", + subject: "Federal Funding for Civic Infrastructure", + keywords: ["Infrastructure", "Finance", "Cities"], + sponsorName: "Pierre Poilievre", + signatureCount: 1250, + deadline: nil, + status: .responseReceived, + petitionURL: URL(string: "https://petitions.ourcommons.ca/en/Petition/Details?Petition=e-4500")!, + governmentResponse: response + ) + let port = SnapshotStubPetitionQueryPort(response: response) + snapshot( + NavigationStack { + PetitionDetailView(petition: petition, queryPort: port) + } + .frame(width: 375, height: 750), + name: "PetitionDetailView_withResponse" + ) + } + + func testPetitionDetailView_awaitingResponse() { + let petition = EPetition( + id: "e-4501", + subject: "Environmental Protections in Northern Canada", + keywords: ["Environment", "North", "Climate Change"], + sponsorName: "Elizabeth May", + signatureCount: 520, + deadline: Date(timeIntervalSince1970: 1_773_600_000), + status: .closed, + petitionURL: URL(string: "https://petitions.ourcommons.ca/en/Petition/Details?Petition=e-4501")! + ) + let port = SnapshotStubPetitionQueryPort(response: nil) + snapshot( + NavigationStack { + PetitionDetailView(petition: petition, queryPort: port) + } + .frame(width: 375, height: 600), + name: "PetitionDetailView_awaitingResponse" + ) + } + + func testPetitionDetailView_notQualified() { + let petition = EPetition( + id: "e-4502", + subject: "Promotion of Amateur Sports Programs", + keywords: ["Sports", "Health", "Youth"], + sponsorName: "Jagmeet Singh", + signatureCount: 230, + deadline: Date(timeIntervalSince1970: 1_773_600_000), + status: .closed, + petitionURL: URL(string: "https://petitions.ourcommons.ca/en/Petition/Details?Petition=e-4502")! + ) + let port = SnapshotStubPetitionQueryPort(response: nil) + snapshot( + NavigationStack { + PetitionDetailView(petition: petition, queryPort: port) + } + .frame(width: 375, height: 600), + name: "PetitionDetailView_notQualified" + ) + } } diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_awaitingResponse.PetitionDetailView_awaitingResponse_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_awaitingResponse.PetitionDetailView_awaitingResponse_a11y.png new file mode 100644 index 00000000..8a653485 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_awaitingResponse.PetitionDetailView_awaitingResponse_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_awaitingResponse.PetitionDetailView_awaitingResponse_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_awaitingResponse.PetitionDetailView_awaitingResponse_dark.png new file mode 100644 index 00000000..4c0bcde4 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_awaitingResponse.PetitionDetailView_awaitingResponse_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_awaitingResponse.PetitionDetailView_awaitingResponse_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_awaitingResponse.PetitionDetailView_awaitingResponse_light.png new file mode 100644 index 00000000..6d225f7f Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_awaitingResponse.PetitionDetailView_awaitingResponse_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_notQualified.PetitionDetailView_notQualified_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_notQualified.PetitionDetailView_notQualified_a11y.png new file mode 100644 index 00000000..ec3aca89 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_notQualified.PetitionDetailView_notQualified_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_notQualified.PetitionDetailView_notQualified_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_notQualified.PetitionDetailView_notQualified_dark.png new file mode 100644 index 00000000..55d79763 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_notQualified.PetitionDetailView_notQualified_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_notQualified.PetitionDetailView_notQualified_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_notQualified.PetitionDetailView_notQualified_light.png new file mode 100644 index 00000000..ebe2f46e Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_notQualified.PetitionDetailView_notQualified_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_withResponse.PetitionDetailView_withResponse_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_withResponse.PetitionDetailView_withResponse_a11y.png new file mode 100644 index 00000000..28ac3e15 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_withResponse.PetitionDetailView_withResponse_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_withResponse.PetitionDetailView_withResponse_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_withResponse.PetitionDetailView_withResponse_dark.png new file mode 100644 index 00000000..764de102 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_withResponse.PetitionDetailView_withResponse_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_withResponse.PetitionDetailView_withResponse_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_withResponse.PetitionDetailView_withResponse_light.png new file mode 100644 index 00000000..c3d9055f Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testPetitionDetailView_withResponse.PetitionDetailView_withResponse_light.png differ